Merge branch 'beta' of github.com:OliveTin/OliveTin into beta
This commit is contained in:
commit
d44ba5f18e
|
|
@ -50,7 +50,7 @@ actions:
|
||||||
popupOnStart: execution-button
|
popupOnStart: execution-button
|
||||||
maxRate:
|
maxRate:
|
||||||
- limit: 3
|
- limit: 3
|
||||||
duration: 5m
|
duration: 1m
|
||||||
|
|
||||||
# You are not limited to operating system commands, and of course you can run
|
# You are not limited to operating system commands, and of course you can run
|
||||||
# your own scripts. Here `maxConcurrent` stops the script running multiple
|
# your own scripts. Here `maxConcurrent` stops the script running multiple
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
||||||
|
import { rateLimits } from '../resources/vue/stores/rateLimits.js'
|
||||||
|
|
||||||
export function initWebsocket () {
|
export function initWebsocket () {
|
||||||
window.addEventListener('EventOutputChunk', onOutputChunk)
|
window.addEventListener('EventOutputChunk', onOutputChunk)
|
||||||
|
window.addEventListener('EventExecutionStarted', onExecutionChanged)
|
||||||
|
window.addEventListener('EventExecutionFinished', onExecutionChanged)
|
||||||
|
|
||||||
reconnectWebsocket()
|
reconnectWebsocket()
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +43,6 @@ function handleEvent (msg) {
|
||||||
break
|
break
|
||||||
case 'EventExecutionFinished':
|
case 'EventExecutionFinished':
|
||||||
case 'EventExecutionStarted':
|
case 'EventExecutionStarted':
|
||||||
buttonResults[msg.event.value.logEntry.executionTrackingId] = msg.event.value.logEntry
|
|
||||||
window.dispatchEvent(j)
|
window.dispatchEvent(j)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
|
@ -59,3 +61,19 @@ function onOutputChunk (evt) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onExecutionChanged (evt) {
|
||||||
|
buttonResults[evt.payload.logEntry.executionTrackingId] = evt.payload.logEntry
|
||||||
|
|
||||||
|
const logEntry = evt.payload.logEntry
|
||||||
|
|
||||||
|
// Update rate limit store from logEntry if rate limit expiry datetime is provided
|
||||||
|
if (logEntry && logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
|
||||||
|
// Parse datetime string "2006-01-02 15:04:05" and convert to Unix timestamp
|
||||||
|
const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T'))
|
||||||
|
rateLimits[logEntry.bindingId] = date.getTime() / 1000
|
||||||
|
} else if (logEntry && logEntry.bindingId) {
|
||||||
|
// Clear rate limit if not set
|
||||||
|
rateLimits[logEntry.bindingId] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ function setupVue (i18nSettings) {
|
||||||
|
|
||||||
function setupErrorDisplay (errorMessage) {
|
function setupErrorDisplay (errorMessage) {
|
||||||
const ErrorApp = {
|
const ErrorApp = {
|
||||||
render() {
|
render () {
|
||||||
return h('section', { class: 'bad', style: 'padding: 2em; text-align: center; margin: 2em auto;' }, [
|
return h('section', { class: 'bad', style: 'padding: 2em; text-align: center; margin: 2em auto;' }, [
|
||||||
h('h2', 'OliveTin Init Failed'),
|
h('h2', 'OliveTin Init Failed'),
|
||||||
h('p', errorMessage),
|
h('p', errorMessage),
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,17 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@connectrpc/connect": "^2.1.1",
|
"@connectrpc/connect": "^2.1.1",
|
||||||
"@connectrpc/connect-web": "^2.1.1",
|
"@connectrpc/connect-web": "^2.1.1",
|
||||||
"@hugeicons/core-free-icons": "^3.1.0",
|
"@hugeicons/core-free-icons": "^3.1.1",
|
||||||
"@hugeicons/vue": "^1.0.4",
|
"@hugeicons/vue": "^1.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"iconify-icon": "^3.0.2",
|
"iconify-icon": "^3.0.2",
|
||||||
"picocrank": "^1.10.0",
|
"picocrank": "^1.13.0",
|
||||||
"standard": "^17.1.2",
|
"standard": "^17.1.2",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.1",
|
||||||
"vue-i18n": "^11.2.7",
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -855,9 +855,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hugeicons/core-free-icons": {
|
"node_modules/@hugeicons/core-free-icons": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.1.tgz",
|
||||||
"integrity": "sha512-DVIdHcPxJ8MyaXaGe3appbaB5z4DWit5RLn8vTy3hsKTrpFEm4QftixfbcHXRqLpOMAoMa2+UXk35qNJ4ZWIsg==",
|
"integrity": "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hugeicons/vue": {
|
"node_modules/@hugeicons/vue": {
|
||||||
|
|
@ -911,13 +911,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "11.2.7",
|
"version": "11.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
|
||||||
"integrity": "sha512-+Ra9I/LAzXDnmv/IrTO03WMCiLya7pHRmGJvNl9fKwx/W4REJ0xaMk2PxCRqnxcBsX443amEMdebQ3R1geiuIw==",
|
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/message-compiler": "11.2.7",
|
"@intlify/message-compiler": "11.2.8",
|
||||||
"@intlify/shared": "11.2.7"
|
"@intlify/shared": "11.2.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
|
|
@ -927,12 +927,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/message-compiler": {
|
"node_modules/@intlify/message-compiler": {
|
||||||
"version": "11.2.7",
|
"version": "11.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
|
||||||
"integrity": "sha512-TFamC+GzJAotAFwUNvbtRVBgvuSn2nCwKNresmPUHv3IIVMmXJt7QQJj/DORI1h8hs46ZF6L0Fs2xBohSOE4iQ==",
|
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/shared": "11.2.7",
|
"@intlify/shared": "11.2.8",
|
||||||
"source-map-js": "^1.0.2"
|
"source-map-js": "^1.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -943,9 +943,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@intlify/shared": {
|
"node_modules/@intlify/shared": {
|
||||||
"version": "11.2.7",
|
"version": "11.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
|
||||||
"integrity": "sha512-uvlkvc/0uQ4FDlHQZccpUnmcOwNcaI3i+69ck2YJ+GqM35AoVbuS63b+YfirV4G0SZh64Ij2UMcFRMmB4nr95w==",
|
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
|
|
@ -2961,9 +2961,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/femtocrank": {
|
"node_modules/femtocrank": {
|
||||||
"version": "2.4.12",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.4.12.tgz",
|
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.0.tgz",
|
||||||
"integrity": "sha512-X2a4WVG1ADGjQcULUyH2FJ4njJNZobfP+iPO1MpAEtWRyVEDxps6dmktbOb3igoyCxNObF0T0uKwUC7zV21C0A==",
|
"integrity": "sha512-plV1HNS/fUzohWJ349kuCBZ3TCfXz7V4F/sY2lVbVWtGXUV+aHxLG6IddAMEf64k2LJ8j0KVrj+nIIKepFaKvg==",
|
||||||
"license": "AGPL-3.0"
|
"license": "AGPL-3.0"
|
||||||
},
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
|
|
@ -4607,17 +4607,17 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picocrank": {
|
"node_modules/picocrank": {
|
||||||
"version": "1.10.0",
|
"version": "1.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.13.0.tgz",
|
||||||
"integrity": "sha512-rpuFopcko5jKuQpnqpvN0Umg9M2qmDI6Z/tVzbnT9PSplwJmtw9wTjgDHyQXujoBBULyurRqtIeQKDPNFLlHOQ==",
|
"integrity": "sha512-kfUU2KVZnHyZ3/s+vM2tw25G+rK//eMjhF7cuXNQm0Ar/o5YvPiLBx8uxCrNNNvhGqb+jQK1Jbn41KO6rAR8Lw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hugeicons/core-free-icons": "^3.1.0",
|
"@hugeicons/core-free-icons": "^3.1.1",
|
||||||
"@hugeicons/vue": "^1.0.4",
|
"@hugeicons/vue": "^1.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"femtocrank": "^2.4.12",
|
"femtocrank": "^2.5.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.1",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
}
|
}
|
||||||
|
|
@ -6122,9 +6122,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
|
|
@ -6246,13 +6246,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "11.2.7",
|
"version": "11.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
|
||||||
"integrity": "sha512-LPv8bAY5OA0UvFEXl4vBQOBqJzRrlExy92tWgRuwW7tbykHf7CH71G2Y4TM2OwGcIS4+hyqKHS2EVBqaYwPY9Q==",
|
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@intlify/core-base": "11.2.7",
|
"@intlify/core-base": "11.2.8",
|
||||||
"@intlify/shared": "11.2.7",
|
"@intlify/shared": "11.2.8",
|
||||||
"@vue/devtools-api": "^6.5.0"
|
"@vue/devtools-api": "^6.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,18 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@connectrpc/connect": "^2.1.1",
|
"@connectrpc/connect": "^2.1.1",
|
||||||
"@connectrpc/connect-web": "^2.1.1",
|
"@connectrpc/connect-web": "^2.1.1",
|
||||||
"@hugeicons/core-free-icons": "^3.1.0",
|
"@hugeicons/core-free-icons": "^3.1.1",
|
||||||
"@hugeicons/vue": "^1.0.4",
|
"@hugeicons/vue": "^1.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"iconify-icon": "^3.0.2",
|
"iconify-icon": "^3.0.2",
|
||||||
"picocrank": "^1.10.0",
|
"picocrank": "^1.13.0",
|
||||||
"standard": "^17.1.2",
|
"standard": "^17.1.2",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.3.0",
|
"vite": "^7.3.1",
|
||||||
"vue-i18n": "^11.2.7",
|
"vue": "^3.5.26",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
||||||
* @generated from field: int32 timeout = 8;
|
* @generated from field: int32 timeout = 8;
|
||||||
*/
|
*/
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||||
|
*
|
||||||
|
* @generated from field: string datetime_rate_limit_expires = 9;
|
||||||
|
*/
|
||||||
|
datetimeRateLimitExpires: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,6 +106,11 @@ export declare type ActionArgument = Message<"olivetin.api.v1.ActionArgument"> &
|
||||||
* @generated from field: map<string, string> suggestions = 7;
|
* @generated from field: map<string, string> suggestions = 7;
|
||||||
*/
|
*/
|
||||||
suggestions: { [key: string]: string };
|
suggestions: { [key: string]: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string suggestions_browser_key = 8;
|
||||||
|
*/
|
||||||
|
suggestionsBrowserKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -476,6 +488,13 @@ export declare type GetLogsRequest = Message<"olivetin.api.v1.GetLogsRequest"> &
|
||||||
* @generated from field: int64 start_offset = 1;
|
* @generated from field: int64 start_offset = 1;
|
||||||
*/
|
*/
|
||||||
startOffset: bigint;
|
startOffset: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional date filter in YYYY-MM-DD format
|
||||||
|
*
|
||||||
|
* @generated from field: string date_filter = 2;
|
||||||
|
*/
|
||||||
|
dateFilter: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -543,11 +562,6 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
|
||||||
*/
|
*/
|
||||||
datetimeFinished: string;
|
datetimeFinished: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* @generated from field: string action_id = 13;
|
|
||||||
*/
|
|
||||||
actionId: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from field: bool execution_started = 14;
|
* @generated from field: bool execution_started = 14;
|
||||||
*/
|
*/
|
||||||
|
|
@ -572,6 +586,20 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
|
||||||
* @generated from field: bool can_kill = 18;
|
* @generated from field: bool can_kill = 18;
|
||||||
*/
|
*/
|
||||||
canKill: boolean;
|
canKill: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||||
|
*
|
||||||
|
* @generated from field: string datetime_rate_limit_expires = 19;
|
||||||
|
*/
|
||||||
|
datetimeRateLimitExpires: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binding ID for matching rate limits to action buttons
|
||||||
|
*
|
||||||
|
* @generated from field: string binding_id = 20;
|
||||||
|
*/
|
||||||
|
bindingId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -904,9 +932,9 @@ export declare type DumpVarsResponse = Message<"olivetin.api.v1.DumpVarsResponse
|
||||||
export declare const DumpVarsResponseSchema: GenMessage<DumpVarsResponse>;
|
export declare const DumpVarsResponseSchema: GenMessage<DumpVarsResponse>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message olivetin.api.v1.ActionEntityPair
|
* @generated from message olivetin.api.v1.DebugBinding
|
||||||
*/
|
*/
|
||||||
export declare type ActionEntityPair = Message<"olivetin.api.v1.ActionEntityPair"> & {
|
export declare type DebugBinding = Message<"olivetin.api.v1.DebugBinding"> & {
|
||||||
/**
|
/**
|
||||||
* @generated from field: string action_title = 1;
|
* @generated from field: string action_title = 1;
|
||||||
*/
|
*/
|
||||||
|
|
@ -919,10 +947,10 @@ export declare type ActionEntityPair = Message<"olivetin.api.v1.ActionEntityPair
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes the message olivetin.api.v1.ActionEntityPair.
|
* Describes the message olivetin.api.v1.DebugBinding.
|
||||||
* Use `create(ActionEntityPairSchema)` to create a new message.
|
* Use `create(DebugBindingSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export declare const ActionEntityPairSchema: GenMessage<ActionEntityPair>;
|
export declare const DebugBindingSchema: GenMessage<DebugBinding>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message olivetin.api.v1.DumpPublicIdActionMapRequest
|
* @generated from message olivetin.api.v1.DumpPublicIdActionMapRequest
|
||||||
|
|
@ -946,9 +974,9 @@ export declare type DumpPublicIdActionMapResponse = Message<"olivetin.api.v1.Dum
|
||||||
alert: string;
|
alert: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from field: map<string, olivetin.api.v1.ActionEntityPair> contents = 2;
|
* @generated from field: map<string, olivetin.api.v1.DebugBinding> contents = 2;
|
||||||
*/
|
*/
|
||||||
contents: { [key: string]: ActionEntityPair };
|
contents: { [key: string]: DebugBinding };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div :id="`actionButton-${actionId}`" role="none" class="action-button">
|
<div :id="`actionButton-${bindingId}`" role="none" class="action-button">
|
||||||
<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
|
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
|
||||||
:class="buttonClasses" @click="handleClick">
|
:class="combinedClasses" @click="handleClick">
|
||||||
|
|
||||||
<div class="navigate-on-start-container">
|
<div class="navigate-on-start-container">
|
||||||
<div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
|
<div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
|
||||||
|
|
@ -18,18 +18,19 @@
|
||||||
<span class="icon" v-html="unicodeIcon"></span>
|
<span class="icon" v-html="unicodeIcon"></span>
|
||||||
<span class="title" aria-live="polite">{{ displayTitle }}
|
<span class="title" aria-live="polite">{{ displayTitle }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import ArgumentForm from './views/ArgumentForm.vue'
|
|
||||||
import { buttonResults } from './stores/buttonResults'
|
import { buttonResults } from './stores/buttonResults'
|
||||||
|
import { rateLimits } from './stores/rateLimits'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||||
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
|
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
import { ref, watch, onMounted, inject } from 'vue'
|
import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const navigateOnStart = ref('')
|
const navigateOnStart = ref('')
|
||||||
|
|
@ -38,10 +39,15 @@ const props = defineProps({
|
||||||
actionData: {
|
actionData: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
cssClass: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const actionId = ref('')
|
const bindingId = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const canExec = ref(true)
|
const canExec = ref(true)
|
||||||
const popupOnStart = ref('')
|
const popupOnStart = ref('')
|
||||||
|
|
@ -54,9 +60,24 @@ const displayTitle = ref('')
|
||||||
const isDisabled = ref(false)
|
const isDisabled = ref(false)
|
||||||
const showArgumentForm = ref(false)
|
const showArgumentForm = ref(false)
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const rateLimitExpires = ref(0)
|
||||||
|
const isRateLimited = ref(false)
|
||||||
|
const rateLimitMessage = ref('')
|
||||||
|
let rateLimitInterval = null
|
||||||
|
|
||||||
// Animation classes
|
// Animation classes
|
||||||
const buttonClasses = ref([])
|
const buttonClasses = ref([])
|
||||||
|
|
||||||
|
// Combined classes including custom cssClass
|
||||||
|
const combinedClasses = computed(() => {
|
||||||
|
const classes = [...buttonClasses.value]
|
||||||
|
if (props.cssClass) {
|
||||||
|
classes.push(props.cssClass)
|
||||||
|
}
|
||||||
|
return classes
|
||||||
|
})
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
const updateIterationTimestamp = ref(0)
|
const updateIterationTimestamp = ref(0)
|
||||||
|
|
||||||
|
|
@ -75,7 +96,7 @@ function constructFromJson(json) {
|
||||||
|
|
||||||
updateFromJson(json)
|
updateFromJson(json)
|
||||||
|
|
||||||
actionId.value = json.bindingId
|
bindingId.value = json.bindingId
|
||||||
title.value = json.title
|
title.value = json.title
|
||||||
canExec.value = json.canExec
|
canExec.value = json.canExec
|
||||||
popupOnStart.value = json.popupOnStart
|
popupOnStart.value = json.popupOnStart
|
||||||
|
|
@ -89,6 +110,19 @@ function constructFromJson(json) {
|
||||||
isDisabled.value = !json.canExec
|
isDisabled.value = !json.canExec
|
||||||
displayTitle.value = title.value
|
displayTitle.value = title.value
|
||||||
unicodeIcon.value = getUnicodeIcon(json.icon)
|
unicodeIcon.value = getUnicodeIcon(json.icon)
|
||||||
|
|
||||||
|
// Initialize rate limit from action data (parse datetime string)
|
||||||
|
if (json.datetimeRateLimitExpires) {
|
||||||
|
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
|
||||||
|
rateLimitExpires.value = date.getTime() / 1000
|
||||||
|
} else {
|
||||||
|
rateLimitExpires.value = 0
|
||||||
|
}
|
||||||
|
// Also initialize the store so the watch picks it up
|
||||||
|
if (bindingId.value) {
|
||||||
|
rateLimits[bindingId.value] = rateLimitExpires.value
|
||||||
|
}
|
||||||
|
updateRateLimitStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFromJson(json) {
|
function updateFromJson(json) {
|
||||||
|
|
@ -96,6 +130,55 @@ function updateFromJson(json) {
|
||||||
// title - as the callback URL relies on it
|
// title - as the callback URL relies on it
|
||||||
|
|
||||||
unicodeIcon.value = getUnicodeIcon(json.icon)
|
unicodeIcon.value = getUnicodeIcon(json.icon)
|
||||||
|
|
||||||
|
// Update rate limiting if changed (parse datetime string)
|
||||||
|
if (json.datetimeRateLimitExpires) {
|
||||||
|
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
|
||||||
|
rateLimitExpires.value = date.getTime() / 1000
|
||||||
|
updateRateLimitStatus()
|
||||||
|
} else if (json.datetimeRateLimitExpires === '') {
|
||||||
|
// Explicitly clear if empty string
|
||||||
|
rateLimitExpires.value = 0
|
||||||
|
updateRateLimitStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRateLimitStatus() {
|
||||||
|
if (rateLimitExpires.value === 0) {
|
||||||
|
isRateLimited.value = false
|
||||||
|
rateLimitMessage.value = ''
|
||||||
|
if (rateLimitInterval) {
|
||||||
|
clearInterval(rateLimitInterval)
|
||||||
|
rateLimitInterval = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const expires = rateLimitExpires.value
|
||||||
|
|
||||||
|
if (now >= expires) {
|
||||||
|
// Rate limit has expired
|
||||||
|
isRateLimited.value = false
|
||||||
|
rateLimitMessage.value = ''
|
||||||
|
rateLimitExpires.value = 0
|
||||||
|
if (rateLimitInterval) {
|
||||||
|
clearInterval(rateLimitInterval)
|
||||||
|
rateLimitInterval = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Still rate limited
|
||||||
|
isRateLimited.value = true
|
||||||
|
const secondsRemaining = expires - now
|
||||||
|
rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
|
||||||
|
|
||||||
|
// Set up interval to update every second
|
||||||
|
if (!rateLimitInterval) {
|
||||||
|
rateLimitInterval = setInterval(() => {
|
||||||
|
updateRateLimitStatus()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClick() {
|
async function handleClick() {
|
||||||
|
|
@ -199,6 +282,30 @@ function onExecStatusChanged() {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
constructFromJson(props.actionData)
|
constructFromJson(props.actionData)
|
||||||
|
|
||||||
|
// Watch the central rate limit store for updates to this button's bindingId
|
||||||
|
// Watch the entire rateLimits object to ensure reactivity with dynamic keys
|
||||||
|
watch(
|
||||||
|
rateLimits,
|
||||||
|
() => {
|
||||||
|
const id = bindingId.value
|
||||||
|
if (id && rateLimits[id] !== undefined) {
|
||||||
|
const newExpires = rateLimits[id]
|
||||||
|
if (newExpires !== rateLimitExpires.value) {
|
||||||
|
rateLimitExpires.value = newExpires
|
||||||
|
updateRateLimitStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rateLimitInterval) {
|
||||||
|
clearInterval(rateLimitInterval)
|
||||||
|
rateLimitInterval = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -256,6 +363,13 @@ watch(
|
||||||
padding: 0.2em;
|
padding: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-button button .rate-limit-message {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: #856404;
|
||||||
|
padding: 0.2em;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/* Animation classes */
|
/* Animation classes */
|
||||||
.action-button button.action-timeout {
|
.action-button button.action-timeout {
|
||||||
background: #fff3cd;
|
background: #fff3cd;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="showNavigation">
|
<Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="sidebarEnabled" :topBarEnabled="topbarEnabled" :navigation="navigation">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<div id="banner" v-if="bannerMessage" :style="bannerCss">
|
<div id="banner" v-if="bannerMessage" :style="bannerCss">
|
||||||
<p>{{ bannerMessage }}</p>
|
<p>{{ bannerMessage }}</p>
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<div id="layout">
|
<div id="layout">
|
||||||
<Navigation ref="navigation" v-if="showNavigation">
|
<Navigation ref="navigation">
|
||||||
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
|
<Sidebar ref="sidebar" id = "mainnav" v-if="sidebarEnabled && showNavigation"/>
|
||||||
</Navigation>
|
</Navigation>
|
||||||
|
|
||||||
<div id="content" initial-martial-complete="{{ hasLoaded }}">
|
<div id="content" initial-martial-complete="{{ hasLoaded }}">
|
||||||
|
|
@ -108,6 +108,7 @@ const showNavigation = ref(true)
|
||||||
const showLogs = ref(true)
|
const showLogs = ref(true)
|
||||||
const showDiagnostics = ref(true)
|
const showDiagnostics = ref(true)
|
||||||
const showLoginLink = ref(true)
|
const showLoginLink = ref(true)
|
||||||
|
const sectionNavigationStyle = ref('sidebar')
|
||||||
|
|
||||||
const languageDialog = ref(null)
|
const languageDialog = ref(null)
|
||||||
const browserLanguages = ref([])
|
const browserLanguages = ref([])
|
||||||
|
|
@ -135,6 +136,15 @@ const currentLanguageName = computed(() => {
|
||||||
return availableLanguages[languagePreference.value] || languagePreference.value
|
return availableLanguages[languagePreference.value] || languagePreference.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed properties for navigation style
|
||||||
|
const topbarEnabled = computed(() => {
|
||||||
|
return sectionNavigationStyle.value === 'topbar'
|
||||||
|
})
|
||||||
|
|
||||||
|
const sidebarEnabled = computed(() => {
|
||||||
|
return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
|
||||||
|
})
|
||||||
|
|
||||||
function normalizeBrowserLanguage() {
|
function normalizeBrowserLanguage() {
|
||||||
const available = Object.keys(combinedTranslations.messages || {})
|
const available = Object.keys(combinedTranslations.messages || {})
|
||||||
|
|
||||||
|
|
@ -180,6 +190,7 @@ function updateHeaderFromInit() {
|
||||||
showNavigation.value = window.initResponse.showNavigation
|
showNavigation.value = window.initResponse.showNavigation
|
||||||
showLogs.value = window.initResponse.showLogList
|
showLogs.value = window.initResponse.showLogList
|
||||||
showDiagnostics.value = window.initResponse.showDiagnostics
|
showDiagnostics.value = window.initResponse.showDiagnostics
|
||||||
|
sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
|
||||||
|
|
||||||
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
|
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
|
||||||
showLoginLink.value = false
|
showLoginLink.value = false
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
<span v-else>{{ component.title }}</span>
|
<span v-else>{{ component.title }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset :class="component.cssClass">
|
||||||
<template v-for="subcomponent in component.contents">
|
<template v-for="subcomponent in component.contents">
|
||||||
<DashboardComponent :component="subcomponent" />
|
<DashboardComponent :component="subcomponent" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<ActionButton v-if="component.type == 'link'" :actionData="component.action" :key="component.title" />
|
<ActionButton v-if="component.type == 'link'" :actionData="component.action" :cssClass="component.cssClass" :key="component.title" />
|
||||||
|
|
||||||
<DashboardComponentDirectory v-else-if="component.type == 'directory'" :component="component" />
|
<DashboardComponentDirectory v-else-if="component.type == 'directory'" :component="component" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<button @click="navigateToDirectory">
|
<button @click="navigateToDirectory" :class="component.cssClass">
|
||||||
<span class="icon" v-html="unicodeIcon"></span>
|
<span class="icon" v-html="unicodeIcon"></span>
|
||||||
<span class="title">{{ component.title }}</span>
|
<span class="title">{{ component.title }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="display">
|
<div class="display" :class="component.cssClass">
|
||||||
<div v-html="component.title" />
|
<div v-html="component.title" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mre-container">
|
<div class="mre-container" :class="component.cssClass">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="executionTrackingId"
|
v-if="executionTrackingId"
|
||||||
:to="`/logs/${executionTrackingId}`"
|
:to="`/logs/${executionTrackingId}`"
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import { buttonResults } from '../stores/buttonResults'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
component: {
|
component: {
|
||||||
|
|
@ -23,7 +24,20 @@ const props = defineProps({
|
||||||
|
|
||||||
const output = ref('Waiting...')
|
const output = ref('Waiting...')
|
||||||
const executionTrackingId = ref(null)
|
const executionTrackingId = ref(null)
|
||||||
let eventListener = null
|
let unwatchButtonResults = null
|
||||||
|
|
||||||
|
function updateFromLogEntry(logEntry) {
|
||||||
|
if (logEntry) {
|
||||||
|
if (logEntry.output !== undefined) {
|
||||||
|
output.value = logEntry.output
|
||||||
|
} else {
|
||||||
|
output.value = 'No output available'
|
||||||
|
}
|
||||||
|
if (logEntry.executionTrackingId) {
|
||||||
|
executionTrackingId.value = logEntry.executionTrackingId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchMostRecentExecution() {
|
async function fetchMostRecentExecution() {
|
||||||
if (!props.component.title) {
|
if (!props.component.title) {
|
||||||
|
|
@ -46,14 +60,7 @@ async function fetchMostRecentExecution() {
|
||||||
const result = await window.client.executionStatus(executionStatusArgs)
|
const result = await window.client.executionStatus(executionStatusArgs)
|
||||||
|
|
||||||
if (result.logEntry) {
|
if (result.logEntry) {
|
||||||
if (result.logEntry.output !== undefined) {
|
updateFromLogEntry(result.logEntry)
|
||||||
output.value = result.logEntry.output
|
|
||||||
} else {
|
|
||||||
output.value = 'No output available'
|
|
||||||
}
|
|
||||||
if (result.logEntry.executionTrackingId) {
|
|
||||||
executionTrackingId.value = result.logEntry.executionTrackingId
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
output.value = 'No output available'
|
output.value = 'No output available'
|
||||||
executionTrackingId.value = null
|
executionTrackingId.value = null
|
||||||
|
|
@ -70,32 +77,39 @@ async function fetchMostRecentExecution() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExecutionFinished(event) {
|
|
||||||
// The dashboard component "title" field is used for lots of things
|
|
||||||
// and in this context for MreOutput it's just to refer to an actionId.
|
|
||||||
//
|
|
||||||
// So this is not a typo.
|
|
||||||
const logEntry = event.payload.logEntry
|
|
||||||
if (logEntry && logEntry.actionId === props.component.title) {
|
|
||||||
if (logEntry.output !== undefined) {
|
|
||||||
output.value = logEntry.output
|
|
||||||
}
|
|
||||||
if (logEntry.executionTrackingId) {
|
|
||||||
executionTrackingId.value = logEntry.executionTrackingId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchMostRecentExecution()
|
fetchMostRecentExecution()
|
||||||
|
|
||||||
eventListener = (event) => handleExecutionFinished(event)
|
unwatchButtonResults = watch(
|
||||||
window.addEventListener('EventExecutionFinished', eventListener)
|
buttonResults,
|
||||||
|
() => {
|
||||||
|
// Find the most recent finished execution for this bindingId
|
||||||
|
const bindingId = props.component.title
|
||||||
|
let mostRecent = null
|
||||||
|
let mostRecentTime = null
|
||||||
|
|
||||||
|
for (const trackingId in buttonResults) {
|
||||||
|
const logEntry = buttonResults[trackingId]
|
||||||
|
if (logEntry && logEntry.bindingId === bindingId && logEntry.executionFinished) {
|
||||||
|
const finishedTime = new Date(logEntry.datetimeFinished)
|
||||||
|
if (!mostRecent || finishedTime > mostRecentTime) {
|
||||||
|
mostRecent = logEntry
|
||||||
|
mostRecentTime = finishedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mostRecent) {
|
||||||
|
updateFromLogEntry(mostRecent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (eventListener) {
|
if (unwatchButtonResults) {
|
||||||
window.removeEventListener('EventExecutionFinished', eventListener)
|
unwatchButtonResults()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ const routes = [
|
||||||
icon: LeftToRightListDashIcon
|
icon: LeftToRightListDashIcon
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/logs/calendar',
|
||||||
|
name: 'LogsCalendar',
|
||||||
|
component: () => import('./views/LogsCalendarView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Logs Calendar',
|
||||||
|
breadcrumb: [
|
||||||
|
{ name: "Logs", href: "/logs" },
|
||||||
|
{ name: "Calendar" },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/entities',
|
path: '/entities',
|
||||||
name: 'Entities',
|
name: 'Entities',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
// Store rate limit expiry times by bindingId
|
||||||
|
// This allows all ActionButton components to reactively update when rate limits change
|
||||||
|
export const rateLimits = reactive({})
|
||||||
|
|
@ -12,10 +12,13 @@
|
||||||
{{ formatLabel(arg.title) }}
|
{{ formatLabel(arg.title) }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<datalist v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0" :id="`${arg.name}-choices`">
|
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
|
||||||
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
||||||
{{ suggestion }}
|
{{ suggestion }}
|
||||||
</option>
|
</option>
|
||||||
|
<option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
|
||||||
|
{{ suggestion }}
|
||||||
|
</option>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
||||||
|
|
@ -28,7 +31,7 @@
|
||||||
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
||||||
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
||||||
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
||||||
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
|
:list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? `${arg.name}-choices` : undefined"
|
||||||
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
||||||
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
||||||
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
|
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
|
||||||
|
|
@ -313,6 +316,60 @@ function getUniqueId() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBrowserSuggestions(arg) {
|
||||||
|
if (!arg.suggestionsBrowserKey) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`olivetin-suggestions-${arg.suggestionsBrowserKey}`)
|
||||||
|
if (stored) {
|
||||||
|
const suggestions = JSON.parse(stored)
|
||||||
|
return Array.isArray(suggestions) ? suggestions : []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load browser suggestions:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBrowserSuggestions() {
|
||||||
|
for (const arg of actionArguments.value) {
|
||||||
|
if (arg.suggestionsBrowserKey) {
|
||||||
|
const value = argValues.value[arg.name]
|
||||||
|
|
||||||
|
// Only save non-empty values for non-checkbox/confirmation/password types
|
||||||
|
if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
|
||||||
|
try {
|
||||||
|
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
let suggestions = []
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
suggestions = JSON.parse(stored)
|
||||||
|
if (!Array.isArray(suggestions)) {
|
||||||
|
suggestions = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add value if not already present
|
||||||
|
if (!suggestions.includes(value)) {
|
||||||
|
suggestions.unshift(value) // Add to beginning
|
||||||
|
// Keep only the most recent 50 suggestions
|
||||||
|
if (suggestions.length > 50) {
|
||||||
|
suggestions = suggestions.slice(0, 50)
|
||||||
|
}
|
||||||
|
localStorage.setItem(key, JSON.stringify(suggestions))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to save browser suggestions:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startAction(actionArgs) {
|
async function startAction(actionArgs) {
|
||||||
const startActionArgs = {
|
const startActionArgs = {
|
||||||
bindingId: props.bindingId,
|
bindingId: props.bindingId,
|
||||||
|
|
@ -356,6 +413,9 @@ async function handleSubmit(event) {
|
||||||
const argvs = getArgumentValues()
|
const argvs = getArgumentValues()
|
||||||
console.log('argument form has elements that passed validation')
|
console.log('argument form has elements that passed validation')
|
||||||
|
|
||||||
|
// Save values to localStorage for arguments with suggestionsBrowserKey
|
||||||
|
saveBrowserSuggestions()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await startAction(argvs)
|
const response = await startAction(argvs)
|
||||||
router.push(`/logs/${response.executionTrackingId}`)
|
router.push(`/logs/${response.executionTrackingId}`)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
<template>
|
||||||
|
<Section :title="t('logs.calendar-title')" :padding="false">
|
||||||
|
<template #toolbar>
|
||||||
|
<router-link to="/logs" class="button neutral">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"/>
|
||||||
|
</svg>
|
||||||
|
{{ t('logs.back-to-list') }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="padding">
|
||||||
|
<Calendar
|
||||||
|
:events="calendarEvents"
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
:current-month="currentMonthIndex"
|
||||||
|
:current-year="currentYear"
|
||||||
|
@event-click="handleEventClick"
|
||||||
|
@date-click="handleDayClick"
|
||||||
|
@month-change="handleMonthChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Calendar from 'picocrank/vue/components/Calendar.vue'
|
||||||
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const logs = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const currentMonthIndex = ref(new Date().getMonth())
|
||||||
|
const currentYear = ref(new Date().getFullYear())
|
||||||
|
|
||||||
|
// Convert logs to calendar events format
|
||||||
|
const calendarEvents = computed(() => {
|
||||||
|
return logs.value
|
||||||
|
.filter(log => {
|
||||||
|
// Only include logs with valid start dates
|
||||||
|
if (!log.datetimeStarted) return false
|
||||||
|
const startDate = new Date(log.datetimeStarted)
|
||||||
|
return !isNaN(startDate.getTime())
|
||||||
|
})
|
||||||
|
.map(log => {
|
||||||
|
const startDate = new Date(log.datetimeStarted)
|
||||||
|
let endDate = log.datetimeFinished ? new Date(log.datetimeFinished) : null
|
||||||
|
|
||||||
|
// Validate end date
|
||||||
|
if (endDate && isNaN(endDate.getTime())) {
|
||||||
|
endDate = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: log.executionTrackingId,
|
||||||
|
title: log.actionTitle || 'Untitled Action',
|
||||||
|
date: startDate,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
actionIcon: log.actionIcon,
|
||||||
|
user: log.user,
|
||||||
|
tags: log.tags,
|
||||||
|
logEntry: log
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Currently fetches only the default page (startOffset: 0)
|
||||||
|
// Multi-page fetching: loop through pages until no more logs or limit reached
|
||||||
|
const allLogs = []
|
||||||
|
let startOffset = BigInt(0)
|
||||||
|
const maxLogs = 10000 // Reasonable limit to prevent excessive API calls
|
||||||
|
const pageSize = 100 // Typical page size, will be updated from API response
|
||||||
|
|
||||||
|
while (allLogs.length < maxLogs) {
|
||||||
|
const args = {
|
||||||
|
"startOffset": startOffset,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await window.client.getLogs(args)
|
||||||
|
const pageLogs = response.logs || []
|
||||||
|
|
||||||
|
// If no logs returned, we've reached the end
|
||||||
|
if (pageLogs.length === 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append logs from this page
|
||||||
|
allLogs.push(...pageLogs)
|
||||||
|
|
||||||
|
// Update offset for next page
|
||||||
|
const currentPageSize = Number(response.pageSize) || pageLogs.length
|
||||||
|
startOffset += BigInt(currentPageSize)
|
||||||
|
|
||||||
|
// If we got fewer logs than the page size, we've reached the end
|
||||||
|
if (pageLogs.length < currentPageSize) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.value = allLogs
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch logs:', err)
|
||||||
|
error.value = 'Failed to load logs'
|
||||||
|
window.showBigError('fetch-logs-calendar', 'getting logs for calendar', err, false)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventClick(event) {
|
||||||
|
// Navigate to the execution view when clicking on a calendar event
|
||||||
|
if (event.id) {
|
||||||
|
router.push(`/logs/${event.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(date) {
|
||||||
|
// Navigate to logs list filtered by the selected date
|
||||||
|
// Format date as YYYY-MM-DD using local date components to avoid timezone issues
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const dateString = `${year}-${month}-${day}`
|
||||||
|
router.push({ path: '/logs', query: { date: dateString } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMonthChange(month, year) {
|
||||||
|
currentMonthIndex.value = month
|
||||||
|
currentYear.value = year
|
||||||
|
// Optionally fetch logs for the new month if needed
|
||||||
|
// For now, we'll keep all logs loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLogs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.padding {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:deep(div.calendar-header-nav) {
|
||||||
|
background-color: var(--bg, #111);
|
||||||
|
color: var(--text-color, #fff);
|
||||||
|
border-color: var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(div.calendar-header-nav h2.calendar-title) {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<Section :title="t('logs.title')" :padding="false">
|
<Section :title="t('logs.title')" :padding="false">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
|
<router-link to="/logs/calendar" class="button neutral">
|
||||||
|
{{ t('logs.calendar') }}
|
||||||
|
</router-link>
|
||||||
<label class="input-with-icons">
|
<label class="input-with-icons">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor"
|
<path fill="currentColor"
|
||||||
|
|
@ -21,7 +24,20 @@
|
||||||
<table class="logs-table">
|
<table class="logs-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('logs.timestamp') }}</th>
|
<th>
|
||||||
|
<div class="timestamp-header">
|
||||||
|
<span>{{ t('logs.timestamp') }}</span>
|
||||||
|
<span v-if="selectedDate" class="date-filter-indicator">
|
||||||
|
<span class="date-filter-text">{{ formatDateFilter(selectedDate) }}</span>
|
||||||
|
<button :title="t('logs.clear-date-filter')" @click="clearDateFilter" class="clear-date-button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th>{{ t('logs.action') }}</th>
|
<th>{{ t('logs.action') }}</th>
|
||||||
<th>{{ t('logs.metadata') }}</th>
|
<th>{{ t('logs.metadata') }}</th>
|
||||||
<th>{{ t('logs.status') }}</th>
|
<th>{{ t('logs.status') }}</th>
|
||||||
|
|
@ -56,7 +72,14 @@
|
||||||
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
|
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="logs.length === 0" class="empty-state">
|
<div v-show="selectedDate && filteredLogs.length === 0" class="empty-state">
|
||||||
|
<p>{{ t('logs.no-logs-to-display') }} {{ formatDateFilter(selectedDate) }}.</p>
|
||||||
|
<button @click="clearDateFilter" class="button neutral">
|
||||||
|
{{ t('logs.clear-date-filter') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="logs.length === 0 && !selectedDate" class="empty-state">
|
||||||
<p>{{ t('logs.no-logs-to-display') }}</p>
|
<p>{{ t('logs.no-logs-to-display') }}</p>
|
||||||
<router-link to="/">{{ t('return-to-index') }}</router-link>
|
<router-link to="/">{{ t('return-to-index') }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -64,27 +87,50 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const logs = ref([])
|
const logs = ref([])
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const totalCount = ref(0)
|
const totalCount = ref(0)
|
||||||
|
const selectedDate = ref(null)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Read date query parameter from route
|
||||||
|
function updateDateFromRoute() {
|
||||||
|
const dateParam = route.query.date
|
||||||
|
if (dateParam) {
|
||||||
|
selectedDate.value = dateParam
|
||||||
|
} else {
|
||||||
|
selectedDate.value = null
|
||||||
|
}
|
||||||
|
// Re-fetch logs when date changes
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for route changes to update date filter
|
||||||
|
watch(() => route.query.date, () => {
|
||||||
|
updateDateFromRoute()
|
||||||
|
})
|
||||||
|
|
||||||
const filteredLogs = computed(() => {
|
const filteredLogs = computed(() => {
|
||||||
let result = logs.value
|
let result = logs.value
|
||||||
|
|
||||||
|
// Date filtering is now done server-side, so we only need to filter by search text
|
||||||
if (searchText.value) {
|
if (searchText.value) {
|
||||||
const searchLower = searchText.value.toLowerCase()
|
const searchLower = searchText.value.toLowerCase()
|
||||||
result = logs.value.filter(log =>
|
result = result.filter(log =>
|
||||||
log.actionTitle.toLowerCase().includes(searchLower)
|
log.actionTitle.toLowerCase().includes(searchLower)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +152,11 @@ async function fetchLogs() {
|
||||||
"startOffset": BigInt(startOffset),
|
"startOffset": BigInt(startOffset),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add date filter if selected
|
||||||
|
if (selectedDate.value) {
|
||||||
|
args.dateFilter = selectedDate.value
|
||||||
|
}
|
||||||
|
|
||||||
const response = await window.client.getLogs(args)
|
const response = await window.client.getLogs(args)
|
||||||
|
|
||||||
logs.value = response.logs
|
logs.value = response.logs
|
||||||
|
|
@ -123,6 +174,24 @@ function clearSearch() {
|
||||||
searchText.value = ''
|
searchText.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearDateFilter() {
|
||||||
|
selectedDate.value = null
|
||||||
|
// Remove date query parameter from URL
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.date
|
||||||
|
router.push({ path: route.path, query })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateFilter(dateString) {
|
||||||
|
// Format YYYY-MM-DD to a short format (e.g., "Jan 15, 2024")
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString + 'T00:00:00')
|
||||||
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
} catch (err) {
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(timestamp) {
|
function formatTimestamp(timestamp) {
|
||||||
if (!timestamp) return 'Unknown'
|
if (!timestamp) return 'Unknown'
|
||||||
try {
|
try {
|
||||||
|
|
@ -141,9 +210,11 @@ function handlePageChange(page) {
|
||||||
function handlePageSizeChange(newPageSize) {
|
function handlePageSizeChange(newPageSize) {
|
||||||
pageSize.value = newPageSize
|
pageSize.value = newPageSize
|
||||||
currentPage.value = 1 // Reset to first page
|
currentPage.value = 1 // Reset to first page
|
||||||
|
fetchLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
updateDateFromRoute()
|
||||||
fetchLogs()
|
fetchLogs()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -227,4 +298,47 @@ onMounted(() => {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timestamp-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter-text {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-header .clear-date-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.125rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-header .clear-date-button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--hover-background, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp-header .clear-date-button svg {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
#
|
||||||
|
# Integration Test Config: EnabledExpression
|
||||||
|
#
|
||||||
|
|
||||||
|
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||||
|
|
||||||
|
logLevel: "DEBUG"
|
||||||
|
checkForUpdates: false
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- title: Turn On Light
|
||||||
|
shell: echo "Turning on light"
|
||||||
|
icon: light
|
||||||
|
entity: light
|
||||||
|
enabledExpression: "{{ eq .CurrentEntity.powered_on false }}"
|
||||||
|
|
||||||
|
- title: Turn Off Light
|
||||||
|
shell: echo "Turning off light"
|
||||||
|
icon: light
|
||||||
|
entity: light
|
||||||
|
enabledExpression: "{{ eq .CurrentEntity.powered_on true }}"
|
||||||
|
|
||||||
|
- title: Always Enabled Action
|
||||||
|
shell: echo "Always enabled"
|
||||||
|
icon: check
|
||||||
|
|
||||||
|
entities:
|
||||||
|
- file: entities/lights.yaml
|
||||||
|
name: light
|
||||||
|
|
||||||
|
dashboards:
|
||||||
|
- title: LightDashboard
|
||||||
|
contents:
|
||||||
|
- title: Light Controls
|
||||||
|
type: fieldset
|
||||||
|
entity: light
|
||||||
|
contents:
|
||||||
|
- title: Turn On Light
|
||||||
|
- title: Turn Off Light
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { describe, it, before, after } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { By, until, Condition } from 'selenium-webdriver'
|
||||||
|
import {
|
||||||
|
getRootAndWait,
|
||||||
|
takeScreenshotOnFailure,
|
||||||
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
|
describe('config: enabledExpression', function () {
|
||||||
|
this.timeout(30000) // Increase timeout for async operations
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
await runner.start('enabledExpression')
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await runner.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
takeScreenshotOnFailure(this.currentTest, webdriver);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Action with enabledExpression for lights enable the correct action', async function() {
|
||||||
|
await getRootAndWait()
|
||||||
|
|
||||||
|
// Navigate to the Lights Dashboard
|
||||||
|
// Use the path with space encoded as %20 - Vue Router should decode it
|
||||||
|
await webdriver.get(runner.baseUrl() + '/dashboards/LightDashboard')
|
||||||
|
|
||||||
|
// Wait for the URL to change and the route to be processed
|
||||||
|
await webdriver.wait(new Condition('wait for URL to contain dashboards', async function() {
|
||||||
|
const url = await webdriver.getCurrentUrl()
|
||||||
|
return url.includes('/dashboards/')
|
||||||
|
}), 5000)
|
||||||
|
|
||||||
|
// Wait for dashboard to load by checking the loaded-dashboard attribute
|
||||||
|
// The attribute should be set to the decoded title "LightDashboard"
|
||||||
|
await webdriver.wait(new Condition('wait for loaded-dashboard', async function() {
|
||||||
|
const body = await webdriver.findElement(By.tagName('body'))
|
||||||
|
const attr = await body.getAttribute('loaded-dashboard')
|
||||||
|
if (attr) {
|
||||||
|
console.log('Current loaded-dashboard attribute:', attr)
|
||||||
|
}
|
||||||
|
// Accept either decoded or encoded version (component should decode, but handle both)
|
||||||
|
return attr === 'LightDashboard'
|
||||||
|
}), 10000)
|
||||||
|
|
||||||
|
// Verify we got the correct dashboard (prefer decoded, but accept encoded)
|
||||||
|
const body = await webdriver.findElement(By.tagName('body'))
|
||||||
|
const attr = await body.getAttribute('loaded-dashboard')
|
||||||
|
if (attr !== 'LightDashboard') {
|
||||||
|
const currentUrl = await webdriver.getCurrentUrl()
|
||||||
|
throw new Error(`Dashboard not loaded correctly. Expected "LightDashboard", got "${attr}". Current URL: ${currentUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for dashboard content to appear - check for dashboard rows first
|
||||||
|
await webdriver.wait(until.elementsLocated(By.css('.dashboard-row')), 5000)
|
||||||
|
|
||||||
|
// Debug: Check what's on the page
|
||||||
|
const dashboardRows = await webdriver.findElements(By.css('.dashboard-row'))
|
||||||
|
console.log(`Found ${dashboardRows.length} dashboard rows`)
|
||||||
|
|
||||||
|
for (let i = 0; i < dashboardRows.length; i++) {
|
||||||
|
const row = dashboardRows[i]
|
||||||
|
const h2Elements = await row.findElements(By.css('h2'))
|
||||||
|
if (h2Elements.length > 0) {
|
||||||
|
const h2Text = await h2Elements[0].getText()
|
||||||
|
console.log(`Row ${i} h2: "${h2Text}"`)
|
||||||
|
}
|
||||||
|
const fieldsets = await row.findElements(By.css('fieldset'))
|
||||||
|
console.log(`Row ${i} has ${fieldsets.length} fieldsets`)
|
||||||
|
if (fieldsets.length > 0) {
|
||||||
|
const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
|
||||||
|
console.log(`Row ${i} fieldset has ${buttons.length} buttons`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find buttons by looking within entity fieldsets
|
||||||
|
// Both rows have h2 title "Light Controls", so we identify them by which buttons are enabled
|
||||||
|
// Living Room Light (powered_on: false) - Turn On should be enabled, Turn Off disabled
|
||||||
|
// Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled
|
||||||
|
let turnOnButton = null
|
||||||
|
let turnOffButton = null
|
||||||
|
|
||||||
|
for (const row of dashboardRows) {
|
||||||
|
// Get the fieldset in this row
|
||||||
|
const fieldsets = await row.findElements(By.css('fieldset'))
|
||||||
|
if (fieldsets.length === 0) continue
|
||||||
|
|
||||||
|
const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
|
||||||
|
|
||||||
|
// Check each button to identify which entity this row represents
|
||||||
|
for (const btn of buttons) {
|
||||||
|
const title = await btn.getAttribute('title')
|
||||||
|
const disabled = await btn.getAttribute('disabled')
|
||||||
|
const isEnabled = disabled === null
|
||||||
|
|
||||||
|
if (title === 'Turn On Light' && isEnabled) {
|
||||||
|
// This is the Living Room Light row (Turn On is enabled because powered_on: false)
|
||||||
|
turnOnButton = btn
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title === 'Turn Off Light' && isEnabled) {
|
||||||
|
// This is the Bedroom Light row (Turn Off is enabled because powered_on: true)
|
||||||
|
turnOffButton = btn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(turnOnButton).to.not.be.null
|
||||||
|
expect(turnOffButton).to.not.be.null
|
||||||
|
|
||||||
|
// Check that Turn On button is enabled (light is off)
|
||||||
|
const turnOnDisabled = await turnOnButton.getAttribute('disabled')
|
||||||
|
expect(turnOnDisabled).to.be.null
|
||||||
|
|
||||||
|
// Check that Turn Off button is enabled (light is on)
|
||||||
|
const turnOffDisabled = await turnOffButton.getAttribute('disabled')
|
||||||
|
expect(turnOffDisabled).to.be.null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Action without enabledExpression is always enabled', async function() {
|
||||||
|
await getRootAndWait()
|
||||||
|
|
||||||
|
// Navigate to actions view
|
||||||
|
await webdriver.get(runner.baseUrl())
|
||||||
|
|
||||||
|
// Wait for action buttons
|
||||||
|
await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000)
|
||||||
|
|
||||||
|
// Find "Always Enabled Action" button
|
||||||
|
const actionButtons = await webdriver.findElements(By.css('.action-button button'))
|
||||||
|
let alwaysEnabledButton = null
|
||||||
|
|
||||||
|
for (const btn of actionButtons) {
|
||||||
|
const title = await btn.getAttribute('title')
|
||||||
|
if (title === 'Always Enabled Action') {
|
||||||
|
alwaysEnabledButton = btn
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(alwaysEnabledButton).to.not.be.null
|
||||||
|
|
||||||
|
// Check that it's enabled
|
||||||
|
const disabled = await alwaysEnabledButton.getAttribute('disabled')
|
||||||
|
expect(disabled).to.be.null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
- name: "Living Room Light"
|
||||||
|
powered_on: false
|
||||||
|
|
||||||
|
- name: "Bedroom Light"
|
||||||
|
powered_on: true
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
logLevel: debug
|
logLevel: debug
|
||||||
|
|
||||||
|
insecureAllowDumpActionMap: true
|
||||||
|
|
||||||
actions:
|
actions:
|
||||||
- title: Check status
|
- title: Check status
|
||||||
id: status_command
|
id: status_command
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,7 @@ describe('config: stdout-most-recent-execution', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stdout-most-recent-execution updates after action execution', async function () {
|
it('stdout-most-recent-execution updates after action execution', async function () {
|
||||||
this.timeout(30000) // Increase timeout for this test
|
this.timeout(45000)
|
||||||
|
|
||||||
await getRootAndWait()
|
await getRootAndWait()
|
||||||
|
|
||||||
// Wait for the mre-output element
|
// Wait for the mre-output element
|
||||||
|
|
@ -105,7 +104,7 @@ describe('config: stdout-most-recent-execution', function () {
|
||||||
await statusButton.click()
|
await statusButton.click()
|
||||||
|
|
||||||
// Wait a moment for the action to start
|
// Wait a moment for the action to start
|
||||||
await webdriver.sleep(500)
|
await webdriver.sleep(2000)
|
||||||
|
|
||||||
// Wait for the output to update (the component listens to EventExecutionFinished events)
|
// Wait for the output to update (the component listens to EventExecutionFinished events)
|
||||||
// We'll wait for the output to change from the initial state
|
// We'll wait for the output to change from the initial state
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||||
|
|
||||||
|
logLevel: "DEBUG"
|
||||||
|
checkForUpdates: false
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- title: Test suggestionsBrowserKey
|
||||||
|
shell: "echo 'Input value: {{ testInput }}, Second input: {{ testInput2 }}'"
|
||||||
|
icon: ping
|
||||||
|
arguments:
|
||||||
|
- name: testInput
|
||||||
|
title: Test Input
|
||||||
|
description: "This input uses suggestionsBrowserKey"
|
||||||
|
suggestionsBrowserKey: test-suggestions-key
|
||||||
|
- name: testInput2
|
||||||
|
title: Test Input 2
|
||||||
|
description: "This input shares the same suggestionsBrowserKey"
|
||||||
|
suggestionsBrowserKey: test-suggestions-key
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { describe, it, before, after } from 'mocha'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { By, Condition } from 'selenium-webdriver'
|
||||||
|
import {
|
||||||
|
getRootAndWait,
|
||||||
|
getActionButton,
|
||||||
|
takeScreenshotOnFailure,
|
||||||
|
getTerminalBuffer,
|
||||||
|
} from '../../lib/elements.js'
|
||||||
|
|
||||||
|
async function openArgumentForm() {
|
||||||
|
await getRootAndWait()
|
||||||
|
const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey')
|
||||||
|
await btn.click()
|
||||||
|
|
||||||
|
await webdriver.wait(
|
||||||
|
new Condition('wait for argument form page', async () => {
|
||||||
|
const url = await webdriver.getCurrentUrl()
|
||||||
|
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
||||||
|
}),
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTestInput() {
|
||||||
|
return await webdriver.findElement(By.id('testInput'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTestInput2() {
|
||||||
|
return await webdriver.findElement(By.id('testInput2'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDatalistOptions(inputName = 'testInput') {
|
||||||
|
return await webdriver.findElements(By.css(`datalist#${inputName}-choices option`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
|
||||||
|
await submitButton.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLogsPage() {
|
||||||
|
await webdriver.wait(
|
||||||
|
new Condition('wait for logs page', async () => {
|
||||||
|
const url = await webdriver.getCurrentUrl()
|
||||||
|
return url.includes('/logs/') && !url.endsWith('/logs')
|
||||||
|
}),
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExecutionComplete() {
|
||||||
|
await webdriver.wait(
|
||||||
|
new Condition('wait for execution status', async () => {
|
||||||
|
const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
|
||||||
|
return statusElements.length > 0
|
||||||
|
}),
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
|
||||||
|
await webdriver.wait(
|
||||||
|
new Condition('wait for execution to finish', async () => {
|
||||||
|
try {
|
||||||
|
const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
|
||||||
|
const statusText = await statusElement.getText()
|
||||||
|
return !statusText.includes('Executing')
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
5000
|
||||||
|
)
|
||||||
|
|
||||||
|
await webdriver.sleep(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocalStorageItem(key) {
|
||||||
|
return await webdriver.executeScript(`return localStorage.getItem('${key}')`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLocalStorage() {
|
||||||
|
await webdriver.executeScript('return localStorage.clear()')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('config: suggestionsBrowserKey', function () {
|
||||||
|
before(async function () {
|
||||||
|
await runner.start('suggestionsBrowserKey')
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await runner.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
takeScreenshotOnFailure(this.currentTest, webdriver)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Input fields with suggestionsBrowserKey are rendered', async function () {
|
||||||
|
await openArgumentForm()
|
||||||
|
|
||||||
|
const input1 = await getTestInput()
|
||||||
|
expect(await input1.getTagName()).to.equal('input')
|
||||||
|
expect(await input1.getAttribute('type')).to.equal('text')
|
||||||
|
|
||||||
|
const label1 = await webdriver.findElement(By.css('label[for="testInput"]'))
|
||||||
|
expect(await label1.getText()).to.contain('Test Input')
|
||||||
|
|
||||||
|
const input2 = await getTestInput2()
|
||||||
|
expect(await input2.getTagName()).to.equal('input')
|
||||||
|
expect(await input2.getAttribute('type')).to.equal('text')
|
||||||
|
|
||||||
|
const label2 = await webdriver.findElement(By.css('label[for="testInput2"]'))
|
||||||
|
expect(await label2.getText()).to.contain('Test Input 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Submitting form saves value to localStorage', async function () {
|
||||||
|
this.timeout(15000)
|
||||||
|
|
||||||
|
// Clear localStorage first
|
||||||
|
await clearLocalStorage()
|
||||||
|
|
||||||
|
await openArgumentForm()
|
||||||
|
|
||||||
|
const input = await getTestInput()
|
||||||
|
const testValue = 'test-value-123'
|
||||||
|
await input.clear()
|
||||||
|
await input.sendKeys(testValue)
|
||||||
|
|
||||||
|
await submitForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
|
||||||
|
// Verify value was saved to localStorage
|
||||||
|
const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
|
||||||
|
expect(stored).to.not.be.null
|
||||||
|
|
||||||
|
const suggestions = JSON.parse(stored)
|
||||||
|
expect(suggestions).to.be.an('array')
|
||||||
|
expect(suggestions).to.include(testValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Previously saved values appear in datalist', async function () {
|
||||||
|
this.timeout(15000)
|
||||||
|
|
||||||
|
// First, save a value to localStorage
|
||||||
|
const testValue = 'saved-suggestion-456'
|
||||||
|
await webdriver.executeScript(`
|
||||||
|
const key = 'olivetin-suggestions-test-suggestions-key';
|
||||||
|
localStorage.setItem(key, JSON.stringify(['${testValue}']));
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Open the form
|
||||||
|
await openArgumentForm()
|
||||||
|
|
||||||
|
// Check that datalist exists and contains the saved value
|
||||||
|
const datalist = await webdriver.findElement(By.id('testInput-choices'))
|
||||||
|
expect(datalist).to.not.be.null
|
||||||
|
|
||||||
|
const options = await getDatalistOptions()
|
||||||
|
expect(options.length).to.be.greaterThan(0)
|
||||||
|
|
||||||
|
// Check if the saved value appears in the datalist
|
||||||
|
let foundValue = false
|
||||||
|
for (const option of options) {
|
||||||
|
const value = await option.getAttribute('value')
|
||||||
|
if (value === testValue) {
|
||||||
|
foundValue = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundValue).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Multiple submissions accumulate suggestions', async function () {
|
||||||
|
this.timeout(20000)
|
||||||
|
|
||||||
|
// Clear localStorage first
|
||||||
|
await clearLocalStorage()
|
||||||
|
|
||||||
|
// Submit first value
|
||||||
|
await openArgumentForm()
|
||||||
|
const input1 = await getTestInput()
|
||||||
|
await input1.clear()
|
||||||
|
await input1.sendKeys('first-value')
|
||||||
|
await submitForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
|
||||||
|
// Submit second value
|
||||||
|
await openArgumentForm()
|
||||||
|
const input2 = await getTestInput()
|
||||||
|
await input2.clear()
|
||||||
|
await input2.sendKeys('second-value')
|
||||||
|
await submitForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
|
||||||
|
// Verify both values are in localStorage
|
||||||
|
const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
|
||||||
|
expect(stored).to.not.be.null
|
||||||
|
|
||||||
|
const suggestions = JSON.parse(stored)
|
||||||
|
expect(suggestions).to.be.an('array')
|
||||||
|
expect(suggestions).to.include('first-value')
|
||||||
|
expect(suggestions).to.include('second-value')
|
||||||
|
expect(suggestions[0]).to.equal('second-value') // Most recent should be first
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Empty values are not saved to localStorage', async function () {
|
||||||
|
this.timeout(15000)
|
||||||
|
|
||||||
|
// Clear localStorage first
|
||||||
|
await clearLocalStorage()
|
||||||
|
|
||||||
|
await openArgumentForm()
|
||||||
|
|
||||||
|
const input = await getTestInput()
|
||||||
|
// Leave input empty (or clear it if it has a default)
|
||||||
|
await input.clear()
|
||||||
|
|
||||||
|
await submitForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
|
||||||
|
// Verify empty value was not saved - localStorage should be null or empty-equivalent
|
||||||
|
const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
|
||||||
|
// Should be null OR empty JSON array string ("[]") OR parse to empty array
|
||||||
|
if (stored !== null) {
|
||||||
|
const suggestions = JSON.parse(stored)
|
||||||
|
expect(suggestions).to.be.an('array')
|
||||||
|
expect(suggestions).to.have.length(0)
|
||||||
|
}
|
||||||
|
// If stored is null, that's also acceptable - no assertion needed
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Suggestions are shared across inputs with the same suggestionsBrowserKey', async function () {
|
||||||
|
this.timeout(20000)
|
||||||
|
|
||||||
|
// Clear localStorage first
|
||||||
|
await clearLocalStorage()
|
||||||
|
|
||||||
|
// Submit a value using the first input
|
||||||
|
await openArgumentForm()
|
||||||
|
const input1 = await getTestInput()
|
||||||
|
await input1.clear()
|
||||||
|
await input1.sendKeys('shared-value-from-input1')
|
||||||
|
await submitForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
|
||||||
|
// Open the form again and verify the value appears in both datalists
|
||||||
|
await openArgumentForm()
|
||||||
|
|
||||||
|
// Check first input's datalist
|
||||||
|
const datalist1 = await webdriver.findElement(By.id('testInput-choices'))
|
||||||
|
expect(datalist1).to.not.be.null
|
||||||
|
const options1 = await getDatalistOptions('testInput')
|
||||||
|
let foundInInput1 = false
|
||||||
|
for (const option of options1) {
|
||||||
|
const value = await option.getAttribute('value')
|
||||||
|
if (value === 'shared-value-from-input1') {
|
||||||
|
foundInInput1 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundInInput1).to.be.true
|
||||||
|
|
||||||
|
// Check second input's datalist
|
||||||
|
const datalist2 = await webdriver.findElement(By.id('testInput2-choices'))
|
||||||
|
expect(datalist2).to.not.be.null
|
||||||
|
const options2 = await getDatalistOptions('testInput2')
|
||||||
|
let foundInInput2 = false
|
||||||
|
for (const option of options2) {
|
||||||
|
const value = await option.getAttribute('value')
|
||||||
|
if (value === 'shared-value-from-input1') {
|
||||||
|
foundInInput2 = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundInInput2).to.be.true
|
||||||
|
|
||||||
|
// Now submit a value using the second input
|
||||||
|
const input2 = await getTestInput2()
|
||||||
|
await input2.clear()
|
||||||
|
await input2.sendKeys('shared-value-from-input2')
|
||||||
|
await submitForm()
|
||||||
|
await waitForLogsPage()
|
||||||
|
await waitForExecutionComplete()
|
||||||
|
|
||||||
|
// Verify both values appear in both datalists
|
||||||
|
await openArgumentForm()
|
||||||
|
|
||||||
|
// Check that both values are in the first input's datalist
|
||||||
|
const options1After = await getDatalistOptions('testInput')
|
||||||
|
let foundValue1 = false
|
||||||
|
let foundValue2 = false
|
||||||
|
for (const option of options1After) {
|
||||||
|
const value = await option.getAttribute('value')
|
||||||
|
if (value === 'shared-value-from-input1') {
|
||||||
|
foundValue1 = true
|
||||||
|
}
|
||||||
|
if (value === 'shared-value-from-input2') {
|
||||||
|
foundValue2 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundValue1).to.be.true
|
||||||
|
expect(foundValue2).to.be.true
|
||||||
|
|
||||||
|
// Check that both values are in the second input's datalist
|
||||||
|
const options2After = await getDatalistOptions('testInput2')
|
||||||
|
foundValue1 = false
|
||||||
|
foundValue2 = false
|
||||||
|
for (const option of options2After) {
|
||||||
|
const value = await option.getAttribute('value')
|
||||||
|
if (value === 'shared-value-from-input1') {
|
||||||
|
foundValue1 = true
|
||||||
|
}
|
||||||
|
if (value === 'shared-value-from-input2') {
|
||||||
|
foundValue2 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundValue1).to.be.true
|
||||||
|
expect(foundValue2).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -27,7 +27,11 @@
|
||||||
"language-dialog.title": "Sprache auswählen",
|
"language-dialog.title": "Sprache auswählen",
|
||||||
"login-button": "Login",
|
"login-button": "Login",
|
||||||
"logs.action": "Aktion",
|
"logs.action": "Aktion",
|
||||||
|
"logs.back-to-list": "Zurück zur Liste",
|
||||||
"logs.blocked": "Blockiert",
|
"logs.blocked": "Blockiert",
|
||||||
|
"logs.calendar": "Kalender",
|
||||||
|
"logs.calendar-title": "Protokoll-Kalender",
|
||||||
|
"logs.clear-date-filter": "Datumsfilter löschen",
|
||||||
"logs.clear-filter": "Suchfilter löschen",
|
"logs.clear-filter": "Suchfilter löschen",
|
||||||
"logs.completed": "Abgeschlossen",
|
"logs.completed": "Abgeschlossen",
|
||||||
"logs.exit-code": "Ausführungscode",
|
"logs.exit-code": "Ausführungscode",
|
||||||
|
|
@ -73,7 +77,11 @@
|
||||||
"language-dialog.title": "Select Language",
|
"language-dialog.title": "Select Language",
|
||||||
"login-button": "Login",
|
"login-button": "Login",
|
||||||
"logs.action": "Action",
|
"logs.action": "Action",
|
||||||
|
"logs.back-to-list": "Back to List",
|
||||||
"logs.blocked": "Blocked",
|
"logs.blocked": "Blocked",
|
||||||
|
"logs.calendar": "Calendar",
|
||||||
|
"logs.calendar-title": "Logs Calendar",
|
||||||
|
"logs.clear-date-filter": "Clear date filter",
|
||||||
"logs.clear-filter": "Clear search filter",
|
"logs.clear-filter": "Clear search filter",
|
||||||
"logs.completed": "Completed",
|
"logs.completed": "Completed",
|
||||||
"logs.exit-code": "Exit code",
|
"logs.exit-code": "Exit code",
|
||||||
|
|
@ -119,7 +127,11 @@
|
||||||
"language-dialog.title": "Seleccionar idioma",
|
"language-dialog.title": "Seleccionar idioma",
|
||||||
"login-button": "Iniciar sesión",
|
"login-button": "Iniciar sesión",
|
||||||
"logs.action": "Acción",
|
"logs.action": "Acción",
|
||||||
|
"logs.back-to-list": "Volver a la Lista",
|
||||||
"logs.blocked": "Bloqueado",
|
"logs.blocked": "Bloqueado",
|
||||||
|
"logs.calendar": "Calendario",
|
||||||
|
"logs.calendar-title": "Calendario de Registros",
|
||||||
|
"logs.clear-date-filter": "Limpiar filtro de fecha",
|
||||||
"logs.clear-filter": "Limpiar filtro de búsqueda",
|
"logs.clear-filter": "Limpiar filtro de búsqueda",
|
||||||
"logs.completed": "Completado",
|
"logs.completed": "Completado",
|
||||||
"logs.exit-code": "Código de salida",
|
"logs.exit-code": "Código de salida",
|
||||||
|
|
@ -165,7 +177,11 @@
|
||||||
"language-dialog.title": "Seleziona lingua",
|
"language-dialog.title": "Seleziona lingua",
|
||||||
"login-button": "Login",
|
"login-button": "Login",
|
||||||
"logs.action": "Azione",
|
"logs.action": "Azione",
|
||||||
|
"logs.back-to-list": "Torna all'Elenco",
|
||||||
"logs.blocked": "Bloccato",
|
"logs.blocked": "Bloccato",
|
||||||
|
"logs.calendar": "Calendario",
|
||||||
|
"logs.calendar-title": "Calendario dei Registri",
|
||||||
|
"logs.clear-date-filter": "Cancella filtro data",
|
||||||
"logs.clear-filter": "Cancella filtro di ricerca",
|
"logs.clear-filter": "Cancella filtro di ricerca",
|
||||||
"logs.completed": "Completato",
|
"logs.completed": "Completato",
|
||||||
"logs.exit-code": "Codice di uscita",
|
"logs.exit-code": "Codice di uscita",
|
||||||
|
|
@ -211,7 +227,11 @@
|
||||||
"language-dialog.title": "选择语言",
|
"language-dialog.title": "选择语言",
|
||||||
"login-button": "登录",
|
"login-button": "登录",
|
||||||
"logs.action": "动作",
|
"logs.action": "动作",
|
||||||
|
"logs.back-to-list": "返回列表",
|
||||||
"logs.blocked": "阻塞",
|
"logs.blocked": "阻塞",
|
||||||
|
"logs.calendar": "日历",
|
||||||
|
"logs.calendar-title": "日志日历",
|
||||||
|
"logs.clear-date-filter": "清除日期筛选器",
|
||||||
"logs.clear-filter": "清除搜索筛选器",
|
"logs.clear-filter": "清除搜索筛选器",
|
||||||
"logs.completed": "完成",
|
"logs.completed": "完成",
|
||||||
"logs.exit-code": "退出代码",
|
"logs.exit-code": "退出代码",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ translations:
|
||||||
logs.exit-code: Ausführungscode
|
logs.exit-code: Ausführungscode
|
||||||
logs.completed: Abgeschlossen
|
logs.completed: Abgeschlossen
|
||||||
logs.clear-filter: Suchfilter löschen
|
logs.clear-filter: Suchfilter löschen
|
||||||
|
logs.clear-date-filter: Datumsfilter löschen
|
||||||
|
logs.calendar: Kalender
|
||||||
|
logs.calendar-title: Protokoll-Kalender
|
||||||
|
logs.back-to-list: Zurück zur Liste
|
||||||
diagnostics.get-support: Unterstützung erhalten
|
diagnostics.get-support: Unterstützung erhalten
|
||||||
diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, einen sosreport von dieser Seite einzufügen.
|
diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, einen sosreport von dieser Seite einzufügen.
|
||||||
diagnostics.where-to-find-help: Wo Sie Hilfe finden
|
diagnostics.where-to-find-help: Wo Sie Hilfe finden
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ translations:
|
||||||
logs.exit-code: Exit code
|
logs.exit-code: Exit code
|
||||||
logs.completed: Completed
|
logs.completed: Completed
|
||||||
logs.clear-filter: Clear search filter
|
logs.clear-filter: Clear search filter
|
||||||
|
logs.clear-date-filter: Clear date filter
|
||||||
|
logs.calendar: Calendar
|
||||||
|
logs.calendar-title: Logs Calendar
|
||||||
|
logs.back-to-list: Back to List
|
||||||
diagnostics.get-support: Get support
|
diagnostics.get-support: Get support
|
||||||
diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
|
diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
|
||||||
diagnostics.where-to-find-help: Where to find help
|
diagnostics.where-to-find-help: Where to find help
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ translations:
|
||||||
logs.exit-code: Código de salida
|
logs.exit-code: Código de salida
|
||||||
logs.completed: Completado
|
logs.completed: Completado
|
||||||
logs.clear-filter: Limpiar filtro de búsqueda
|
logs.clear-filter: Limpiar filtro de búsqueda
|
||||||
|
logs.clear-date-filter: Limpiar filtro de fecha
|
||||||
|
logs.calendar: Calendario
|
||||||
|
logs.calendar-title: Calendario de Registros
|
||||||
|
logs.back-to-list: Volver a la Lista
|
||||||
diagnostics.get-support: Obtener soporte
|
diagnostics.get-support: Obtener soporte
|
||||||
diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir un sosreport de esta página.
|
diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir un sosreport de esta página.
|
||||||
diagnostics.where-to-find-help: Dónde encontrar ayuda
|
diagnostics.where-to-find-help: Dónde encontrar ayuda
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ translations:
|
||||||
logs.exit-code: Codice di uscita
|
logs.exit-code: Codice di uscita
|
||||||
logs.completed: Completato
|
logs.completed: Completato
|
||||||
logs.clear-filter: Cancella filtro di ricerca
|
logs.clear-filter: Cancella filtro di ricerca
|
||||||
|
logs.clear-date-filter: Cancella filtro data
|
||||||
|
logs.calendar: Calendario
|
||||||
|
logs.calendar-title: Calendario dei Registri
|
||||||
|
logs.back-to-list: Torna all'Elenco
|
||||||
diagnostics.get-support: Ottenere supporto
|
diagnostics.get-support: Ottenere supporto
|
||||||
diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere un sosreport da questa pagina.
|
diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere un sosreport da questa pagina.
|
||||||
diagnostics.where-to-find-help: Dove trovare aiuto
|
diagnostics.where-to-find-help: Dove trovare aiuto
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ translations:
|
||||||
logs.exit-code: 退出代码
|
logs.exit-code: 退出代码
|
||||||
logs.completed: 完成
|
logs.completed: 完成
|
||||||
logs.clear-filter: 清除搜索筛选器
|
logs.clear-filter: 清除搜索筛选器
|
||||||
|
logs.clear-date-filter: 清除日期筛选器
|
||||||
|
logs.calendar: 日历
|
||||||
|
logs.calendar-title: 日志日历
|
||||||
|
logs.back-to-list: 返回列表
|
||||||
diagnostics.get-support: 获取支持
|
diagnostics.get-support: 获取支持
|
||||||
diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含 sosreport 将非常有帮助。
|
diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含 sosreport 将非常有帮助。
|
||||||
diagnostics.where-to-find-help: 在哪里找到帮助
|
diagnostics.where-to-find-help: 在哪里找到帮助
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ message Action {
|
||||||
string popup_on_start = 6;
|
string popup_on_start = 6;
|
||||||
int32 order = 7;
|
int32 order = 7;
|
||||||
int32 timeout = 8;
|
int32 timeout = 8;
|
||||||
|
string datetime_rate_limit_expires = 9; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||||
}
|
}
|
||||||
|
|
||||||
message ActionArgument {
|
message ActionArgument {
|
||||||
|
|
@ -25,6 +26,7 @@ message ActionArgument {
|
||||||
|
|
||||||
string description = 6;
|
string description = 6;
|
||||||
map<string, string> suggestions = 7;
|
map<string, string> suggestions = 7;
|
||||||
|
string suggestions_browser_key = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ActionArgumentChoice {
|
message ActionArgumentChoice {
|
||||||
|
|
@ -118,6 +120,7 @@ message StartActionByGetAndWaitResponse {
|
||||||
|
|
||||||
message GetLogsRequest{
|
message GetLogsRequest{
|
||||||
int64 start_offset = 1;
|
int64 start_offset = 1;
|
||||||
|
string date_filter = 2; // Optional date filter in YYYY-MM-DD format
|
||||||
};
|
};
|
||||||
|
|
||||||
message LogEntry {
|
message LogEntry {
|
||||||
|
|
@ -132,12 +135,13 @@ message LogEntry {
|
||||||
repeated string tags = 10;
|
repeated string tags = 10;
|
||||||
string execution_tracking_id = 11;
|
string execution_tracking_id = 11;
|
||||||
string datetime_finished = 12;
|
string datetime_finished = 12;
|
||||||
string action_id = 13;
|
|
||||||
bool execution_started = 14;
|
bool execution_started = 14;
|
||||||
bool execution_finished = 15;
|
bool execution_finished = 15;
|
||||||
bool blocked = 16;
|
bool blocked = 16;
|
||||||
int64 datetime_index = 17;
|
int64 datetime_index = 17;
|
||||||
bool can_kill = 18;
|
bool can_kill = 18;
|
||||||
|
string datetime_rate_limit_expires = 19; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||||
|
string binding_id = 20; // Binding ID for matching rate limits to action buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetLogsResponse {
|
message GetLogsResponse {
|
||||||
|
|
@ -215,7 +219,7 @@ message DumpVarsResponse {
|
||||||
map<string, string> contents = 2;
|
map<string, string> contents = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ActionEntityPair {
|
message DebugBinding {
|
||||||
string action_title = 1;
|
string action_title = 1;
|
||||||
string entity_prefix = 2;
|
string entity_prefix = 2;
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +227,7 @@ message ActionEntityPair {
|
||||||
message DumpPublicIdActionMapRequest {}
|
message DumpPublicIdActionMapRequest {}
|
||||||
message DumpPublicIdActionMapResponse {
|
message DumpPublicIdActionMapResponse {
|
||||||
string alert = 1;
|
string alert = 1;
|
||||||
map<string, ActionEntityPair> contents = 2;
|
map<string, DebugBinding> contents = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetReadyzRequest {}
|
message GetReadyzRequest {}
|
||||||
|
|
|
||||||
|
|
@ -22,17 +22,18 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Action struct {
|
type Action struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`
|
BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`
|
||||||
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"`
|
Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"`
|
||||||
CanExec bool `protobuf:"varint,4,opt,name=can_exec,json=canExec,proto3" json:"can_exec,omitempty"`
|
CanExec bool `protobuf:"varint,4,opt,name=can_exec,json=canExec,proto3" json:"can_exec,omitempty"`
|
||||||
Arguments []*ActionArgument `protobuf:"bytes,5,rep,name=arguments,proto3" json:"arguments,omitempty"`
|
Arguments []*ActionArgument `protobuf:"bytes,5,rep,name=arguments,proto3" json:"arguments,omitempty"`
|
||||||
PopupOnStart string `protobuf:"bytes,6,opt,name=popup_on_start,json=popupOnStart,proto3" json:"popup_on_start,omitempty"`
|
PopupOnStart string `protobuf:"bytes,6,opt,name=popup_on_start,json=popupOnStart,proto3" json:"popup_on_start,omitempty"`
|
||||||
Order int32 `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"`
|
Order int32 `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"`
|
||||||
Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"`
|
Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
DatetimeRateLimitExpires string `protobuf:"bytes,9,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||||
sizeCache protoimpl.SizeCache
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Action) Reset() {
|
func (x *Action) Reset() {
|
||||||
|
|
@ -121,17 +122,25 @@ func (x *Action) GetTimeout() int32 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *Action) GetDatetimeRateLimitExpires() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DatetimeRateLimitExpires
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ActionArgument struct {
|
type ActionArgument struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||||
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
|
||||||
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
|
Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"`
|
||||||
DefaultValue string `protobuf:"bytes,4,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"`
|
DefaultValue string `protobuf:"bytes,4,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"`
|
||||||
Choices []*ActionArgumentChoice `protobuf:"bytes,5,rep,name=choices,proto3" json:"choices,omitempty"`
|
Choices []*ActionArgumentChoice `protobuf:"bytes,5,rep,name=choices,proto3" json:"choices,omitempty"`
|
||||||
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
Suggestions map[string]string `protobuf:"bytes,7,rep,name=suggestions,proto3" json:"suggestions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
Suggestions map[string]string `protobuf:"bytes,7,rep,name=suggestions,proto3" json:"suggestions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||||
unknownFields protoimpl.UnknownFields
|
SuggestionsBrowserKey string `protobuf:"bytes,8,opt,name=suggestions_browser_key,json=suggestionsBrowserKey,proto3" json:"suggestions_browser_key,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ActionArgument) Reset() {
|
func (x *ActionArgument) Reset() {
|
||||||
|
|
@ -213,6 +222,13 @@ func (x *ActionArgument) GetSuggestions() map[string]string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ActionArgument) GetSuggestionsBrowserKey() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SuggestionsBrowserKey
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ActionArgumentChoice struct {
|
type ActionArgumentChoice struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
|
Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
|
||||||
|
|
@ -1088,6 +1104,7 @@ func (x *StartActionByGetAndWaitResponse) GetLogEntry() *LogEntry {
|
||||||
type GetLogsRequest struct {
|
type GetLogsRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
StartOffset int64 `protobuf:"varint,1,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"`
|
StartOffset int64 `protobuf:"varint,1,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"`
|
||||||
|
DateFilter string `protobuf:"bytes,2,opt,name=date_filter,json=dateFilter,proto3" json:"date_filter,omitempty"` // Optional date filter in YYYY-MM-DD format
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -1129,27 +1146,35 @@ func (x *GetLogsRequest) GetStartOffset() int64 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *GetLogsRequest) GetDateFilter() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DateFilter
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
DatetimeStarted string `protobuf:"bytes,1,opt,name=datetime_started,json=datetimeStarted,proto3" json:"datetime_started,omitempty"`
|
DatetimeStarted string `protobuf:"bytes,1,opt,name=datetime_started,json=datetimeStarted,proto3" json:"datetime_started,omitempty"`
|
||||||
ActionTitle string `protobuf:"bytes,2,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"`
|
ActionTitle string `protobuf:"bytes,2,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"`
|
||||||
Output string `protobuf:"bytes,3,opt,name=output,proto3" json:"output,omitempty"`
|
Output string `protobuf:"bytes,3,opt,name=output,proto3" json:"output,omitempty"`
|
||||||
TimedOut bool `protobuf:"varint,5,opt,name=timed_out,json=timedOut,proto3" json:"timed_out,omitempty"`
|
TimedOut bool `protobuf:"varint,5,opt,name=timed_out,json=timedOut,proto3" json:"timed_out,omitempty"`
|
||||||
ExitCode int32 `protobuf:"varint,6,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
|
ExitCode int32 `protobuf:"varint,6,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
|
||||||
User string `protobuf:"bytes,7,opt,name=user,proto3" json:"user,omitempty"`
|
User string `protobuf:"bytes,7,opt,name=user,proto3" json:"user,omitempty"`
|
||||||
UserClass string `protobuf:"bytes,8,opt,name=user_class,json=userClass,proto3" json:"user_class,omitempty"`
|
UserClass string `protobuf:"bytes,8,opt,name=user_class,json=userClass,proto3" json:"user_class,omitempty"`
|
||||||
ActionIcon string `protobuf:"bytes,9,opt,name=action_icon,json=actionIcon,proto3" json:"action_icon,omitempty"`
|
ActionIcon string `protobuf:"bytes,9,opt,name=action_icon,json=actionIcon,proto3" json:"action_icon,omitempty"`
|
||||||
Tags []string `protobuf:"bytes,10,rep,name=tags,proto3" json:"tags,omitempty"`
|
Tags []string `protobuf:"bytes,10,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||||
ExecutionTrackingId string `protobuf:"bytes,11,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"`
|
ExecutionTrackingId string `protobuf:"bytes,11,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"`
|
||||||
DatetimeFinished string `protobuf:"bytes,12,opt,name=datetime_finished,json=datetimeFinished,proto3" json:"datetime_finished,omitempty"`
|
DatetimeFinished string `protobuf:"bytes,12,opt,name=datetime_finished,json=datetimeFinished,proto3" json:"datetime_finished,omitempty"`
|
||||||
ActionId string `protobuf:"bytes,13,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
|
ExecutionStarted bool `protobuf:"varint,14,opt,name=execution_started,json=executionStarted,proto3" json:"execution_started,omitempty"`
|
||||||
ExecutionStarted bool `protobuf:"varint,14,opt,name=execution_started,json=executionStarted,proto3" json:"execution_started,omitempty"`
|
ExecutionFinished bool `protobuf:"varint,15,opt,name=execution_finished,json=executionFinished,proto3" json:"execution_finished,omitempty"`
|
||||||
ExecutionFinished bool `protobuf:"varint,15,opt,name=execution_finished,json=executionFinished,proto3" json:"execution_finished,omitempty"`
|
Blocked bool `protobuf:"varint,16,opt,name=blocked,proto3" json:"blocked,omitempty"`
|
||||||
Blocked bool `protobuf:"varint,16,opt,name=blocked,proto3" json:"blocked,omitempty"`
|
DatetimeIndex int64 `protobuf:"varint,17,opt,name=datetime_index,json=datetimeIndex,proto3" json:"datetime_index,omitempty"`
|
||||||
DatetimeIndex int64 `protobuf:"varint,17,opt,name=datetime_index,json=datetimeIndex,proto3" json:"datetime_index,omitempty"`
|
CanKill bool `protobuf:"varint,18,opt,name=can_kill,json=canKill,proto3" json:"can_kill,omitempty"`
|
||||||
CanKill bool `protobuf:"varint,18,opt,name=can_kill,json=canKill,proto3" json:"can_kill,omitempty"`
|
DatetimeRateLimitExpires string `protobuf:"bytes,19,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||||
unknownFields protoimpl.UnknownFields
|
BindingId string `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` // Binding ID for matching rate limits to action buttons
|
||||||
sizeCache protoimpl.SizeCache
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *LogEntry) Reset() {
|
func (x *LogEntry) Reset() {
|
||||||
|
|
@ -1259,13 +1284,6 @@ func (x *LogEntry) GetDatetimeFinished() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *LogEntry) GetActionId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.ActionId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *LogEntry) GetExecutionStarted() bool {
|
func (x *LogEntry) GetExecutionStarted() bool {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.ExecutionStarted
|
return x.ExecutionStarted
|
||||||
|
|
@ -1301,6 +1319,20 @@ func (x *LogEntry) GetCanKill() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LogEntry) GetDatetimeRateLimitExpires() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.DatetimeRateLimitExpires
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *LogEntry) GetBindingId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.BindingId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type GetLogsResponse struct {
|
type GetLogsResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
|
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
|
||||||
|
|
@ -2089,7 +2121,7 @@ func (x *DumpVarsResponse) GetContents() map[string]string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionEntityPair struct {
|
type DebugBinding struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ActionTitle string `protobuf:"bytes,1,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"`
|
ActionTitle string `protobuf:"bytes,1,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"`
|
||||||
EntityPrefix string `protobuf:"bytes,2,opt,name=entity_prefix,json=entityPrefix,proto3" json:"entity_prefix,omitempty"`
|
EntityPrefix string `protobuf:"bytes,2,opt,name=entity_prefix,json=entityPrefix,proto3" json:"entity_prefix,omitempty"`
|
||||||
|
|
@ -2097,20 +2129,20 @@ type ActionEntityPair struct {
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ActionEntityPair) Reset() {
|
func (x *DebugBinding) Reset() {
|
||||||
*x = ActionEntityPair{}
|
*x = DebugBinding{}
|
||||||
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
|
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ActionEntityPair) String() string {
|
func (x *DebugBinding) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*ActionEntityPair) ProtoMessage() {}
|
func (*DebugBinding) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ActionEntityPair) ProtoReflect() protoreflect.Message {
|
func (x *DebugBinding) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
|
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
|
@ -2122,19 +2154,19 @@ func (x *ActionEntityPair) ProtoReflect() protoreflect.Message {
|
||||||
return mi.MessageOf(x)
|
return mi.MessageOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: Use ActionEntityPair.ProtoReflect.Descriptor instead.
|
// Deprecated: Use DebugBinding.ProtoReflect.Descriptor instead.
|
||||||
func (*ActionEntityPair) Descriptor() ([]byte, []int) {
|
func (*DebugBinding) Descriptor() ([]byte, []int) {
|
||||||
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35}
|
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ActionEntityPair) GetActionTitle() string {
|
func (x *DebugBinding) GetActionTitle() string {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.ActionTitle
|
return x.ActionTitle
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ActionEntityPair) GetEntityPrefix() string {
|
func (x *DebugBinding) GetEntityPrefix() string {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.EntityPrefix
|
return x.EntityPrefix
|
||||||
}
|
}
|
||||||
|
|
@ -2178,9 +2210,9 @@ func (*DumpPublicIdActionMapRequest) Descriptor() ([]byte, []int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type DumpPublicIdActionMapResponse struct {
|
type DumpPublicIdActionMapResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Alert string `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"`
|
Alert string `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"`
|
||||||
Contents map[string]*ActionEntityPair `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
Contents map[string]*DebugBinding `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -2222,7 +2254,7 @@ func (x *DumpPublicIdActionMapResponse) GetAlert() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*ActionEntityPair {
|
func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*DebugBinding {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.Contents
|
return x.Contents
|
||||||
}
|
}
|
||||||
|
|
@ -3839,7 +3871,7 @@ var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\x81\x02\n" +
|
"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\xc0\x02\n" +
|
||||||
"\x06Action\x12\x1d\n" +
|
"\x06Action\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" +
|
"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" +
|
||||||
|
|
@ -3849,7 +3881,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\targuments\x18\x05 \x03(\v2\x1f.olivetin.api.v1.ActionArgumentR\targuments\x12$\n" +
|
"\targuments\x18\x05 \x03(\v2\x1f.olivetin.api.v1.ActionArgumentR\targuments\x12$\n" +
|
||||||
"\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" +
|
"\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" +
|
||||||
"\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" +
|
"\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" +
|
||||||
"\atimeout\x18\b \x01(\x05R\atimeout\"\xea\x02\n" +
|
"\atimeout\x18\b \x01(\x05R\atimeout\x12=\n" +
|
||||||
|
"\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\"\xa2\x03\n" +
|
||||||
"\x0eActionArgument\x12\x12\n" +
|
"\x0eActionArgument\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
|
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
|
||||||
"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
|
"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
|
||||||
|
|
@ -3857,7 +3890,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\rdefault_value\x18\x04 \x01(\tR\fdefaultValue\x12?\n" +
|
"\rdefault_value\x18\x04 \x01(\tR\fdefaultValue\x12?\n" +
|
||||||
"\achoices\x18\x05 \x03(\v2%.olivetin.api.v1.ActionArgumentChoiceR\achoices\x12 \n" +
|
"\achoices\x18\x05 \x03(\v2%.olivetin.api.v1.ActionArgumentChoiceR\achoices\x12 \n" +
|
||||||
"\vdescription\x18\x06 \x01(\tR\vdescription\x12R\n" +
|
"\vdescription\x18\x06 \x01(\tR\vdescription\x12R\n" +
|
||||||
"\vsuggestions\x18\a \x03(\v20.olivetin.api.v1.ActionArgument.SuggestionsEntryR\vsuggestions\x1a>\n" +
|
"\vsuggestions\x18\a \x03(\v20.olivetin.api.v1.ActionArgument.SuggestionsEntryR\vsuggestions\x126\n" +
|
||||||
|
"\x17suggestions_browser_key\x18\b \x01(\tR\x15suggestionsBrowserKey\x1a>\n" +
|
||||||
"\x10SuggestionsEntry\x12\x10\n" +
|
"\x10SuggestionsEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +
|
||||||
|
|
@ -3922,9 +3956,11 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\x1eStartActionByGetAndWaitRequest\x12\x1b\n" +
|
"\x1eStartActionByGetAndWaitRequest\x12\x1b\n" +
|
||||||
"\taction_id\x18\x01 \x01(\tR\bactionId\"Y\n" +
|
"\taction_id\x18\x01 \x01(\tR\bactionId\"Y\n" +
|
||||||
"\x1fStartActionByGetAndWaitResponse\x126\n" +
|
"\x1fStartActionByGetAndWaitResponse\x126\n" +
|
||||||
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"3\n" +
|
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"T\n" +
|
||||||
"\x0eGetLogsRequest\x12!\n" +
|
"\x0eGetLogsRequest\x12!\n" +
|
||||||
"\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\"\xc8\x04\n" +
|
"\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\x12\x1f\n" +
|
||||||
|
"\vdate_filter\x18\x02 \x01(\tR\n" +
|
||||||
|
"dateFilter\"\x89\x05\n" +
|
||||||
"\bLogEntry\x12)\n" +
|
"\bLogEntry\x12)\n" +
|
||||||
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
||||||
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
||||||
|
|
@ -3939,13 +3975,15 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\x04tags\x18\n" +
|
"\x04tags\x18\n" +
|
||||||
" \x03(\tR\x04tags\x122\n" +
|
" \x03(\tR\x04tags\x122\n" +
|
||||||
"\x15execution_tracking_id\x18\v \x01(\tR\x13executionTrackingId\x12+\n" +
|
"\x15execution_tracking_id\x18\v \x01(\tR\x13executionTrackingId\x12+\n" +
|
||||||
"\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12\x1b\n" +
|
"\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12+\n" +
|
||||||
"\taction_id\x18\r \x01(\tR\bactionId\x12+\n" +
|
|
||||||
"\x11execution_started\x18\x0e \x01(\bR\x10executionStarted\x12-\n" +
|
"\x11execution_started\x18\x0e \x01(\bR\x10executionStarted\x12-\n" +
|
||||||
"\x12execution_finished\x18\x0f \x01(\bR\x11executionFinished\x12\x18\n" +
|
"\x12execution_finished\x18\x0f \x01(\bR\x11executionFinished\x12\x18\n" +
|
||||||
"\ablocked\x18\x10 \x01(\bR\ablocked\x12%\n" +
|
"\ablocked\x18\x10 \x01(\bR\ablocked\x12%\n" +
|
||||||
"\x0edatetime_index\x18\x11 \x01(\x03R\rdatetimeIndex\x12\x19\n" +
|
"\x0edatetime_index\x18\x11 \x01(\x03R\rdatetimeIndex\x12\x19\n" +
|
||||||
"\bcan_kill\x18\x12 \x01(\bR\acanKill\"\xca\x01\n" +
|
"\bcan_kill\x18\x12 \x01(\bR\acanKill\x12=\n" +
|
||||||
|
"\x1bdatetime_rate_limit_expires\x18\x13 \x01(\tR\x18datetimeRateLimitExpires\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"binding_id\x18\x14 \x01(\tR\tbindingId\"\xca\x01\n" +
|
||||||
"\x0fGetLogsResponse\x12-\n" +
|
"\x0fGetLogsResponse\x12-\n" +
|
||||||
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
|
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
|
||||||
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
|
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
|
||||||
|
|
@ -3997,17 +4035,17 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||||
"\bcontents\x18\x02 \x03(\v2/.olivetin.api.v1.DumpVarsResponse.ContentsEntryR\bcontents\x1a;\n" +
|
"\bcontents\x18\x02 \x03(\v2/.olivetin.api.v1.DumpVarsResponse.ContentsEntryR\bcontents\x1a;\n" +
|
||||||
"\rContentsEntry\x12\x10\n" +
|
"\rContentsEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Z\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"V\n" +
|
||||||
"\x10ActionEntityPair\x12!\n" +
|
"\fDebugBinding\x12!\n" +
|
||||||
"\faction_title\x18\x01 \x01(\tR\vactionTitle\x12#\n" +
|
"\faction_title\x18\x01 \x01(\tR\vactionTitle\x12#\n" +
|
||||||
"\rentity_prefix\x18\x02 \x01(\tR\fentityPrefix\"\x1e\n" +
|
"\rentity_prefix\x18\x02 \x01(\tR\fentityPrefix\"\x1e\n" +
|
||||||
"\x1cDumpPublicIdActionMapRequest\"\xef\x01\n" +
|
"\x1cDumpPublicIdActionMapRequest\"\xeb\x01\n" +
|
||||||
"\x1dDumpPublicIdActionMapResponse\x12\x14\n" +
|
"\x1dDumpPublicIdActionMapResponse\x12\x14\n" +
|
||||||
"\x05alert\x18\x01 \x01(\tR\x05alert\x12X\n" +
|
"\x05alert\x18\x01 \x01(\tR\x05alert\x12X\n" +
|
||||||
"\bcontents\x18\x02 \x03(\v2<.olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntryR\bcontents\x1a^\n" +
|
"\bcontents\x18\x02 \x03(\v2<.olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntryR\bcontents\x1aZ\n" +
|
||||||
"\rContentsEntry\x12\x10\n" +
|
"\rContentsEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x123\n" +
|
||||||
"\x05value\x18\x02 \x01(\v2!.olivetin.api.v1.ActionEntityPairR\x05value:\x028\x01\"\x12\n" +
|
"\x05value\x18\x02 \x01(\v2\x1d.olivetin.api.v1.DebugBindingR\x05value:\x028\x01\"\x12\n" +
|
||||||
"\x10GetReadyzRequest\"+\n" +
|
"\x10GetReadyzRequest\"+\n" +
|
||||||
"\x11GetReadyzResponse\x12\x16\n" +
|
"\x11GetReadyzResponse\x12\x16\n" +
|
||||||
"\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" +
|
"\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" +
|
||||||
|
|
@ -4181,7 +4219,7 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
|
||||||
(*SosReportResponse)(nil), // 32: olivetin.api.v1.SosReportResponse
|
(*SosReportResponse)(nil), // 32: olivetin.api.v1.SosReportResponse
|
||||||
(*DumpVarsRequest)(nil), // 33: olivetin.api.v1.DumpVarsRequest
|
(*DumpVarsRequest)(nil), // 33: olivetin.api.v1.DumpVarsRequest
|
||||||
(*DumpVarsResponse)(nil), // 34: olivetin.api.v1.DumpVarsResponse
|
(*DumpVarsResponse)(nil), // 34: olivetin.api.v1.DumpVarsResponse
|
||||||
(*ActionEntityPair)(nil), // 35: olivetin.api.v1.ActionEntityPair
|
(*DebugBinding)(nil), // 35: olivetin.api.v1.DebugBinding
|
||||||
(*DumpPublicIdActionMapRequest)(nil), // 36: olivetin.api.v1.DumpPublicIdActionMapRequest
|
(*DumpPublicIdActionMapRequest)(nil), // 36: olivetin.api.v1.DumpPublicIdActionMapRequest
|
||||||
(*DumpPublicIdActionMapResponse)(nil), // 37: olivetin.api.v1.DumpPublicIdActionMapResponse
|
(*DumpPublicIdActionMapResponse)(nil), // 37: olivetin.api.v1.DumpPublicIdActionMapResponse
|
||||||
(*GetReadyzRequest)(nil), // 38: olivetin.api.v1.GetReadyzRequest
|
(*GetReadyzRequest)(nil), // 38: olivetin.api.v1.GetReadyzRequest
|
||||||
|
|
@ -4250,7 +4288,7 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
|
||||||
0, // 27: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
|
0, // 27: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
|
||||||
65, // 28: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
|
65, // 28: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
|
||||||
3, // 29: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
|
3, // 29: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
|
||||||
35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.ActionEntityPair
|
35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
|
||||||
6, // 31: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
|
6, // 31: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
|
||||||
9, // 32: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
|
9, // 32: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
|
||||||
12, // 33: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
|
12, // 33: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,14 +10,15 @@ require (
|
||||||
connectrpc.com/connect v1.19.1
|
connectrpc.com/connect v1.19.1
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/MicahParks/keyfunc/v3 v3.7.0
|
github.com/MicahParks/keyfunc/v3 v3.7.0
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.1
|
||||||
github.com/alexedwards/argon2id v1.0.0
|
github.com/alexedwards/argon2id v1.0.0
|
||||||
github.com/bufbuild/buf v1.61.0
|
github.com/bufbuild/buf v1.63.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/fzipp/gocyclo v0.6.0
|
github.com/fzipp/gocyclo v0.6.0
|
||||||
github.com/go-critic/go-critic v0.14.2
|
github.com/go-critic/go-critic v0.14.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c
|
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac
|
||||||
github.com/knadh/koanf/parsers/yaml v1.1.0
|
github.com/knadh/koanf/parsers/yaml v1.1.0
|
||||||
github.com/knadh/koanf/providers/env v1.1.0
|
github.com/knadh/koanf/providers/env v1.1.0
|
||||||
github.com/knadh/koanf/providers/file v1.2.1
|
github.com/knadh/koanf/providers/file v1.2.1
|
||||||
|
|
@ -51,13 +52,14 @@ require (
|
||||||
buf.build/go/spdx v0.2.0 // indirect
|
buf.build/go/spdx v0.2.0 // indirect
|
||||||
buf.build/go/standard v0.1.0 // indirect
|
buf.build/go/standard v0.1.0 // indirect
|
||||||
cel.dev/expr v0.25.1 // indirect
|
cel.dev/expr v0.25.1 // indirect
|
||||||
connectrpc.com/otelconnect v0.8.0 // indirect
|
connectrpc.com/otelconnect v0.9.0 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
github.com/MicahParks/jwkset v0.11.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/PaesslerAG/gval v1.2.4 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect
|
github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 // indirect
|
||||||
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect
|
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cli/browser v1.3.0 // indirect
|
github.com/cli/browser v1.3.0 // indirect
|
||||||
|
|
@ -112,7 +114,7 @@ require (
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
||||||
github.com/quasilyte/gogrep v0.5.0 // indirect
|
github.com/quasilyte/gogrep v0.5.0 // indirect
|
||||||
|
|
@ -125,6 +127,7 @@ require (
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.1 // indirect
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
github.com/segmentio/encoding v0.5.3 // indirect
|
github.com/segmentio/encoding v0.5.3 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/stoewer/go-strcase v1.3.1 // indirect
|
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
||||||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||||
connectrpc.com/otelconnect v0.8.0 h1:a4qrN4H8aEE2jAoCxheZYYfEjXMgVPyL9OzPQLBEFXU=
|
connectrpc.com/otelconnect v0.8.0 h1:a4qrN4H8aEE2jAoCxheZYYfEjXMgVPyL9OzPQLBEFXU=
|
||||||
connectrpc.com/otelconnect v0.8.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
|
connectrpc.com/otelconnect v0.8.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
|
||||||
|
connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA=
|
||||||
|
connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
|
|
@ -42,6 +44,13 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3
|
||||||
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
|
||||||
|
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
|
||||||
|
github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU=
|
||||||
|
github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
|
||||||
|
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
|
||||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
|
|
@ -54,8 +63,12 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo
|
||||||
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
||||||
github.com/bufbuild/buf v1.61.0 h1:JPaK/RM2eoheyzznW+1LxaFgN6xjBCi8s25q2kUbH9A=
|
github.com/bufbuild/buf v1.61.0 h1:JPaK/RM2eoheyzznW+1LxaFgN6xjBCi8s25q2kUbH9A=
|
||||||
github.com/bufbuild/buf v1.61.0/go.mod h1:Xs3leBmxjL5tTnSVYfNwNXHXD1k5et3fR/tJyIyQl4s=
|
github.com/bufbuild/buf v1.61.0/go.mod h1:Xs3leBmxjL5tTnSVYfNwNXHXD1k5et3fR/tJyIyQl4s=
|
||||||
|
github.com/bufbuild/buf v1.63.0 h1:vMIRozWqYcOU992FqcGgAp8LjoWVTzr52qEolUT+xi4=
|
||||||
|
github.com/bufbuild/buf v1.63.0/go.mod h1:IWF+TIxwmk4DeyDmguN8WhxKFKQitcX2WnP+RlJlDiY=
|
||||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e h1:LQA+1MyiPkolGHJGC2GMDC5Xu+0RDVH6jGMKech7Exs=
|
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e h1:LQA+1MyiPkolGHJGC2GMDC5Xu+0RDVH6jGMKech7Exs=
|
||||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI=
|
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI=
|
||||||
|
github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 h1:cQYwUyAzyMmYr7AyJU1C6pVCpUrJJBkmx7UunZosxxs=
|
||||||
|
github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI=
|
||||||
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU=
|
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU=
|
||||||
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
|
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
|
@ -152,6 +165,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
|
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
|
||||||
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
|
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
|
||||||
|
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac h1:JQ6AC9tf2xhwTxMY9nuIeOPM7Cj0BDeCNgKPrNgQvtQ=
|
||||||
|
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
|
||||||
github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
|
github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
|
||||||
github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
|
github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
|
||||||
github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88=
|
github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88=
|
||||||
|
|
@ -216,6 +231,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4=
|
github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4=
|
||||||
|
|
@ -242,12 +259,17 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
|
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
|
||||||
|
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||||
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
|
@ -293,6 +315,7 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
|
|
@ -305,6 +328,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6
|
||||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||||
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||||
auth "github.com/OliveTin/OliveTin/internal/auth"
|
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||||
|
|
@ -275,23 +276,37 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
|
||||||
|
if logEntry.Binding == nil || logEntry.Binding.Action == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
|
||||||
|
if expiryUnix <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
|
func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
|
||||||
pble := &apiv1.LogEntry{
|
pble := &apiv1.LogEntry{
|
||||||
ActionTitle: logEntry.ActionTitle,
|
ActionTitle: logEntry.ActionTitle,
|
||||||
ActionIcon: logEntry.ActionIcon,
|
ActionIcon: logEntry.ActionIcon,
|
||||||
ActionId: logEntry.ActionId,
|
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
|
||||||
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
|
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
|
||||||
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
|
DatetimeIndex: logEntry.Index,
|
||||||
DatetimeIndex: logEntry.Index,
|
Output: logEntry.Output,
|
||||||
Output: logEntry.Output,
|
TimedOut: logEntry.TimedOut,
|
||||||
TimedOut: logEntry.TimedOut,
|
Blocked: logEntry.Blocked,
|
||||||
Blocked: logEntry.Blocked,
|
ExitCode: logEntry.ExitCode,
|
||||||
ExitCode: logEntry.ExitCode,
|
Tags: logEntry.Tags,
|
||||||
Tags: logEntry.Tags,
|
ExecutionTrackingId: logEntry.ExecutionTrackingID,
|
||||||
ExecutionTrackingId: logEntry.ExecutionTrackingID,
|
ExecutionStarted: logEntry.ExecutionStarted,
|
||||||
ExecutionStarted: logEntry.ExecutionStarted,
|
ExecutionFinished: logEntry.ExecutionFinished,
|
||||||
ExecutionFinished: logEntry.ExecutionFinished,
|
User: logEntry.Username,
|
||||||
User: logEntry.Username,
|
BindingId: logEntry.Binding.ID,
|
||||||
|
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pble.ExecutionFinished {
|
if !pble.ExecutionFinished {
|
||||||
|
|
@ -311,10 +326,20 @@ func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string
|
||||||
return logEntry
|
return logEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
|
// This is the actual action ID, not the binding ID.
|
||||||
|
func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
|
||||||
var ile *executor.InternalLogEntry
|
var ile *executor.InternalLogEntry
|
||||||
|
|
||||||
logs := api.executor.GetLogsByActionId(actionId)
|
binding := api.executor.FindBindingByID(actionId)
|
||||||
|
if binding == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := api.executor.GetLogsByBindingId(binding.ID)
|
||||||
|
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(logs) == 0 {
|
if len(logs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -341,7 +366,7 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
|
||||||
ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
|
ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
ile = getMostRecentExecutionStatusById(api, req.Msg.ActionId)
|
ile = getMostRecentExecutionStatusByActionId(api, req.Msg.ActionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ile == nil {
|
if ile == nil {
|
||||||
|
|
@ -474,7 +499,11 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := &apiv1.GetLogsResponse{}
|
ret := &apiv1.GetLogsResponse{}
|
||||||
logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
|
dateFilter := ""
|
||||||
|
if req.Msg.DateFilter != "" {
|
||||||
|
dateFilter = req.Msg.DateFilter
|
||||||
|
}
|
||||||
|
logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize, dateFilter)
|
||||||
for _, le := range logEntries {
|
for _, le := range logEntries {
|
||||||
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
||||||
}
|
}
|
||||||
|
|
@ -539,7 +568,7 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
|
filtered := api.filterLogsByACL(api.executor.GetLogsByBindingId(req.Msg.ActionId), user)
|
||||||
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
|
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
|
||||||
if page.empty {
|
if page.empty {
|
||||||
return connect.NewResponse(buildEmptyPageResponse(page)), nil
|
return connect.NewResponse(buildEmptyPageResponse(page)), nil
|
||||||
|
|
@ -688,7 +717,7 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum
|
||||||
|
|
||||||
func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
|
func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
|
||||||
res := &apiv1.DumpPublicIdActionMapResponse{}
|
res := &apiv1.DumpPublicIdActionMapResponse{}
|
||||||
res.Contents = make(map[string]*apiv1.ActionEntityPair)
|
res.Contents = make(map[string]*apiv1.DebugBinding)
|
||||||
|
|
||||||
if !api.cfg.InsecureAllowDumpActionMap {
|
if !api.cfg.InsecureAllowDumpActionMap {
|
||||||
res.Alert = "Dumping Public IDs is disallowed."
|
res.Alert = "Dumping Public IDs is disallowed."
|
||||||
|
|
@ -696,16 +725,15 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ
|
||||||
return connect.NewResponse(res), nil
|
return connect.NewResponse(res), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
api.executor.MapActionIdToBindingLock.RLock()
|
api.executor.MapActionBindingsLock.RLock()
|
||||||
|
|
||||||
for k, v := range api.executor.MapActionIdToBinding {
|
for k, v := range api.executor.MapActionBindings {
|
||||||
res.Contents[k] = &apiv1.ActionEntityPair{
|
res.Contents[k] = &apiv1.DebugBinding{
|
||||||
ActionTitle: v.Action.Title,
|
ActionTitle: v.Action.Title,
|
||||||
EntityPrefix: "?",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api.executor.MapActionIdToBindingLock.RUnlock()
|
api.executor.MapActionBindingsLock.RUnlock()
|
||||||
|
|
||||||
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
|
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
|
||||||
|
|
||||||
|
|
@ -723,6 +751,10 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge
|
||||||
func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
|
func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
|
||||||
log.Debugf("EventStream: %v", req.Msg)
|
log.Debugf("EventStream: %v", req.Msg)
|
||||||
|
|
||||||
|
// Set X-Accel-Buffering header to disable nginx buffering for this stream
|
||||||
|
// https://github.com/OliveTin/OliveTin/issues/765
|
||||||
|
srv.ResponseHeader().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||||
|
|
||||||
if err := api.checkDashboardAccess(user); err != nil {
|
if err := api.checkDashboardAccess(user); err != nil {
|
||||||
|
|
@ -809,7 +841,7 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
|
||||||
toRemove := []*streamingClient{}
|
toRemove := []*streamingClient{}
|
||||||
|
|
||||||
for _, client := range api.copyOfStreamingClients() {
|
for _, client := range api.copyOfStreamingClients() {
|
||||||
|
|
@ -817,7 +849,7 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
||||||
case client.channel <- &apiv1.EventStreamResponse{
|
case client.channel <- &apiv1.EventStreamResponse{
|
||||||
Event: &apiv1.EventStreamResponse_ExecutionFinished{
|
Event: &apiv1.EventStreamResponse_ExecutionFinished{
|
||||||
ExecutionFinished: &apiv1.EventExecutionFinished{
|
ExecutionFinished: &apiv1.EventExecutionFinished{
|
||||||
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
|
LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}:
|
}:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||||
|
|
@ -22,10 +28,10 @@ func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
|
func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
|
||||||
rr.ex.MapActionIdToBindingLock.RLock()
|
rr.ex.MapActionBindingsLock.RLock()
|
||||||
defer rr.ex.MapActionIdToBindingLock.RUnlock()
|
defer rr.ex.MapActionBindingsLock.RUnlock()
|
||||||
|
|
||||||
for _, binding := range rr.ex.MapActionIdToBinding {
|
for _, binding := range rr.ex.MapActionBindings {
|
||||||
if binding.Action.Title != title {
|
if binding.Action.Title != title {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -55,28 +61,84 @@ func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePo
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func evaluateEnabledExpression(action *config.Action, entity *entities.Entity) bool {
|
||||||
|
if action.EnabledExpression == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
result := entities.ParseTemplateWith(action.EnabledExpression, entity)
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTemplateError(result, action) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluateResultValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTemplateError(result string, action *config.Action) bool {
|
||||||
|
if !strings.HasPrefix(result, "tpl ") || !strings.Contains(result, "error") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": action.Title,
|
||||||
|
"enabledExpression": action.EnabledExpression,
|
||||||
|
"result": result,
|
||||||
|
}).Warn("enabledExpression template evaluation failed, treating as disabled")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateResultValue(result string) bool {
|
||||||
|
if strings.EqualFold(result, "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if num, err := strconv.Atoi(result); err == nil {
|
||||||
|
return num != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
|
func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
|
||||||
action := actionBinding.Action
|
action := actionBinding.Action
|
||||||
|
|
||||||
|
aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action)
|
||||||
|
enabledExprCanExec := evaluateEnabledExpression(action, actionBinding.Entity)
|
||||||
|
|
||||||
|
// Calculate rate limit expiry time
|
||||||
|
expiryUnix := rr.ex.GetTimeUntilAvailable(actionBinding)
|
||||||
|
datetimeRateLimitExpires := ""
|
||||||
|
if expiryUnix > 0 {
|
||||||
|
datetimeRateLimitExpires = time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
btn := apiv1.Action{
|
btn := apiv1.Action{
|
||||||
BindingId: actionBinding.ID,
|
BindingId: actionBinding.ID,
|
||||||
Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity),
|
Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity),
|
||||||
Icon: entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
|
Icon: entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
|
||||||
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
|
CanExec: aclCanExec && enabledExprCanExec,
|
||||||
PopupOnStart: action.PopupOnStart,
|
PopupOnStart: action.PopupOnStart,
|
||||||
Order: int32(actionBinding.ConfigOrder),
|
Order: int32(actionBinding.ConfigOrder),
|
||||||
Timeout: int32(action.Timeout),
|
Timeout: int32(action.Timeout),
|
||||||
|
DatetimeRateLimitExpires: datetimeRateLimitExpires,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cfgArg := range action.Arguments {
|
for _, cfgArg := range action.Arguments {
|
||||||
pbArg := apiv1.ActionArgument{
|
pbArg := apiv1.ActionArgument{
|
||||||
Name: cfgArg.Name,
|
Name: cfgArg.Name,
|
||||||
Title: cfgArg.Title,
|
Title: cfgArg.Title,
|
||||||
Type: cfgArg.Type,
|
Type: cfgArg.Type,
|
||||||
Description: cfgArg.Description,
|
Description: cfgArg.Description,
|
||||||
DefaultValue: cfgArg.Default,
|
DefaultValue: cfgArg.Default,
|
||||||
Choices: buildChoices(cfgArg),
|
Choices: buildChoices(cfgArg),
|
||||||
Suggestions: cfgArg.Suggestions,
|
Suggestions: cfgArg.Suggestions,
|
||||||
|
SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.Arguments = append(btn.Arguments, &pbArg)
|
btn.Arguments = append(btn.Arguments, &pbArg)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
|
|
||||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||||
apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
|
apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
|
||||||
|
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||||
config "github.com/OliveTin/OliveTin/internal/config"
|
config "github.com/OliveTin/OliveTin/internal/config"
|
||||||
"github.com/OliveTin/OliveTin/internal/entities"
|
"github.com/OliveTin/OliveTin/internal/entities"
|
||||||
"github.com/OliveTin/OliveTin/internal/executor"
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
|
@ -20,7 +21,7 @@ import (
|
||||||
"path"
|
"path"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
|
func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
|
||||||
ex := executor.DefaultExecutor(injectedConfig)
|
ex := executor.DefaultExecutor(injectedConfig)
|
||||||
ex.RebuildActionMap()
|
ex.RebuildActionMap()
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ func TestGetActionsAndStart(t *testing.T) {
|
||||||
ex := executor.DefaultExecutor(cfg)
|
ex := executor.DefaultExecutor(cfg)
|
||||||
ex.RebuildActionMap()
|
ex.RebuildActionMap()
|
||||||
|
|
||||||
conn, client := getNewTestServerAndClient(t, cfg)
|
conn, client := getNewTestServerAndClient(cfg)
|
||||||
|
|
||||||
respInit, errInit := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
|
respInit, errInit := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
|
||||||
respGetReady, errReady := client.GetReadyz(context.Background(), connect.NewRequest(&apiv1.GetReadyzRequest{}))
|
respGetReady, errReady := client.GetReadyz(context.Background(), connect.NewRequest(&apiv1.GetReadyzRequest{}))
|
||||||
|
|
@ -98,7 +99,7 @@ func TestGetActionsAndStart(t *testing.T) {
|
||||||
func TestGetEntities(t *testing.T) {
|
func TestGetEntities(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
|
|
||||||
ts, client := getNewTestServerAndClient(t, cfg)
|
ts, client := getNewTestServerAndClient(cfg)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
setupTestEntities()
|
setupTestEntities()
|
||||||
|
|
@ -173,3 +174,164 @@ func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceCli
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEnabledExpression(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expression string
|
||||||
|
entity *entities.Entity
|
||||||
|
expectedResult bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty expression returns true",
|
||||||
|
expression: "",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal true returns true",
|
||||||
|
expression: "true",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal True returns true (case insensitive)",
|
||||||
|
expression: "True",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal 1 returns true",
|
||||||
|
expression: "1",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal false returns false",
|
||||||
|
expression: "false",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "literal 0 returns false",
|
||||||
|
expression: "0",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty result returns false",
|
||||||
|
expression: "{{ .NonExistent }}",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expression with CurrentEntity true",
|
||||||
|
expression: "{{ eq .CurrentEntity.powered_on true }}",
|
||||||
|
entity: &entities.Entity{Data: map[string]any{"powered_on": true}},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expression with CurrentEntity false",
|
||||||
|
expression: "{{ eq .CurrentEntity.powered_on true }}",
|
||||||
|
entity: &entities.Entity{Data: map[string]any{"powered_on": false}},
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expression with CurrentEntity integer 1",
|
||||||
|
expression: "{{ .CurrentEntity.status }}",
|
||||||
|
entity: &entities.Entity{Data: map[string]any{"status": 1}},
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expression with CurrentEntity integer 0",
|
||||||
|
expression: "{{ .CurrentEntity.status }}",
|
||||||
|
entity: &entities.Entity{Data: map[string]any{"status": 0}},
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template parse error returns false",
|
||||||
|
expression: "{{ invalid syntax }}",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template exec error returns false",
|
||||||
|
expression: "{{ .CurrentEntity.nonexistent }}",
|
||||||
|
entity: nil,
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
action := &config.Action{
|
||||||
|
EnabledExpression: tt.expression,
|
||||||
|
}
|
||||||
|
result := evaluateEnabledExpression(action, tt.entity)
|
||||||
|
assert.Equal(t, tt.expectedResult, result, "evaluateEnabledExpression should return expected result")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildActionWithEnabledExpression(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.DefaultPermissions.Exec = true
|
||||||
|
|
||||||
|
action := &config.Action{
|
||||||
|
Title: "Test Action",
|
||||||
|
Shell: "echo test",
|
||||||
|
EnabledExpression: "{{ eq .CurrentEntity.enabled true }}",
|
||||||
|
}
|
||||||
|
cfg.Actions = append(cfg.Actions, action)
|
||||||
|
|
||||||
|
ex := executor.DefaultExecutor(cfg)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
|
||||||
|
binding := findBindingByTitle(ex, "Test Action")
|
||||||
|
assert.NotNil(t, binding, "Binding should be found")
|
||||||
|
|
||||||
|
rr := &DashboardRenderRequest{
|
||||||
|
AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "testuser"},
|
||||||
|
cfg: cfg,
|
||||||
|
ex: ex,
|
||||||
|
}
|
||||||
|
|
||||||
|
testWithEntity(t, binding, rr, true, true, "Action should be executable when entity.enabled is true")
|
||||||
|
testWithEntity(t, binding, rr, false, false, "Action should not be executable when entity.enabled is false")
|
||||||
|
|
||||||
|
bindingNoExpr := findBindingByTitle(ex, "Test Action No Expression")
|
||||||
|
if bindingNoExpr == nil {
|
||||||
|
actionNoExpression := &config.Action{
|
||||||
|
Title: "Test Action No Expression",
|
||||||
|
Shell: "echo test",
|
||||||
|
}
|
||||||
|
cfg.Actions = append(cfg.Actions, actionNoExpression)
|
||||||
|
ex.RebuildActionMap()
|
||||||
|
bindingNoExpr = findBindingByTitle(ex, "Test Action No Expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
actionResult := buildAction(bindingNoExpr, rr)
|
||||||
|
assert.True(t, actionResult.CanExec, "Action without enabledExpression should be executable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBindingByTitle(ex *executor.Executor, title string) *executor.ActionBinding {
|
||||||
|
ex.MapActionBindingsLock.RLock()
|
||||||
|
defer ex.MapActionBindingsLock.RUnlock()
|
||||||
|
|
||||||
|
for _, b := range ex.MapActionBindings {
|
||||||
|
if b.Action.Title == title {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *DashboardRenderRequest, enabled bool, expectedCanExec bool, message string) {
|
||||||
|
binding.Entity = &entities.Entity{
|
||||||
|
UniqueKey: "test-entity",
|
||||||
|
Data: map[string]any{"enabled": enabled},
|
||||||
|
}
|
||||||
|
|
||||||
|
actionResult := buildAction(binding, rr)
|
||||||
|
assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
|
||||||
Contents: make([]*apiv1.DashboardComponent, 0),
|
Contents: make([]*apiv1.DashboardComponent, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, binding := range rr.ex.MapActionIdToBinding {
|
for _, binding := range rr.ex.MapActionBindings {
|
||||||
if binding.Action.Hidden {
|
if binding.Action.Hidden {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,25 +22,28 @@ type Action struct {
|
||||||
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
|
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
|
||||||
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
|
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
|
||||||
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
|
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
|
||||||
|
ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"`
|
||||||
Triggers []string `koanf:"triggers"`
|
Triggers []string `koanf:"triggers"`
|
||||||
MaxConcurrent int `koanf:"maxConcurrent"`
|
MaxConcurrent int `koanf:"maxConcurrent"`
|
||||||
MaxRate []RateSpec `koanf:"maxRate"`
|
MaxRate []RateSpec `koanf:"maxRate"`
|
||||||
Arguments []ActionArgument `koanf:"arguments"`
|
Arguments []ActionArgument `koanf:"arguments"`
|
||||||
PopupOnStart string `koanf:"popupOnStart"`
|
PopupOnStart string `koanf:"popupOnStart"`
|
||||||
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
||||||
|
EnabledExpression string `koanf:"enabledExpression"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionArgument objects appear on Actions.
|
// ActionArgument objects appear on Actions.
|
||||||
type ActionArgument struct {
|
type ActionArgument struct {
|
||||||
Name string `koanf:"name"`
|
Name string `koanf:"name"`
|
||||||
Title string `koanf:"title"`
|
Title string `koanf:"title"`
|
||||||
Description string `koanf:"description"`
|
Description string `koanf:"description"`
|
||||||
Type string `koanf:"type"`
|
Type string `koanf:"type"`
|
||||||
Default string `koanf:"default"`
|
Default string `koanf:"default"`
|
||||||
Choices []ActionArgumentChoice `koanf:"choices"`
|
Choices []ActionArgumentChoice `koanf:"choices"`
|
||||||
Entity string `koanf:"entity"`
|
Entity string `koanf:"entity"`
|
||||||
RejectNull bool `koanf:"rejectNull"`
|
RejectNull bool `koanf:"rejectNull"`
|
||||||
Suggestions map[string]string `koanf:"suggestions"`
|
Suggestions map[string]string `koanf:"suggestions"`
|
||||||
|
SuggestionsBrowserKey string `koanf:"suggestionsBrowserKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionArgumentChoice represents a predefined choice for an argument.
|
// ActionArgumentChoice represents a predefined choice for an argument.
|
||||||
|
|
@ -55,6 +58,18 @@ type RateSpec struct {
|
||||||
Duration string `koanf:"duration"`
|
Duration string `koanf:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookConfig defines configuration for generic webhook triggers.
|
||||||
|
type WebhookConfig struct {
|
||||||
|
Secret string `koanf:"secret"` // Optional: secret for signature verification
|
||||||
|
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
|
||||||
|
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
|
||||||
|
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
|
||||||
|
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
|
||||||
|
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
|
||||||
|
Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions
|
||||||
|
Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr")
|
||||||
|
}
|
||||||
|
|
||||||
// Entity represents a "thing" that can have multiple actions associated with it.
|
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||||
// for example, a media player with a start and stop action.
|
// for example, a media player with a start and stop action.
|
||||||
type EntityFile struct {
|
type EntityFile struct {
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,12 @@ type ActionBinding struct {
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
logs map[string]*InternalLogEntry
|
logs map[string]*InternalLogEntry
|
||||||
logsTrackingIdsByDate []string
|
logsTrackingIdsByDate []string
|
||||||
LogsByActionId map[string][]*InternalLogEntry
|
LogsByBindingId map[string][]*InternalLogEntry
|
||||||
|
|
||||||
logmutex sync.RWMutex
|
logmutex sync.RWMutex
|
||||||
|
|
||||||
MapActionIdToBinding map[string]*ActionBinding
|
MapActionBindings map[string]*ActionBinding
|
||||||
MapActionIdToBindingLock sync.RWMutex
|
MapActionBindingsLock sync.RWMutex
|
||||||
|
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
|
|
||||||
|
|
@ -86,7 +86,6 @@ type ExecutionRequest struct {
|
||||||
// easily serializable.
|
// easily serializable.
|
||||||
type InternalLogEntry struct {
|
type InternalLogEntry struct {
|
||||||
Binding *ActionBinding
|
Binding *ActionBinding
|
||||||
BindingID string
|
|
||||||
DatetimeStarted time.Time
|
DatetimeStarted time.Time
|
||||||
DatetimeFinished time.Time
|
DatetimeFinished time.Time
|
||||||
Output string
|
Output string
|
||||||
|
|
@ -110,7 +109,6 @@ type InternalLogEntry struct {
|
||||||
*/
|
*/
|
||||||
ActionTitle string
|
ActionTitle string
|
||||||
ActionIcon string
|
ActionIcon string
|
||||||
ActionId string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type executorStepFunc func(*ExecutionRequest) bool
|
type executorStepFunc func(*ExecutionRequest) bool
|
||||||
|
|
@ -122,8 +120,8 @@ func DefaultExecutor(cfg *config.Config) *Executor {
|
||||||
e.Cfg = cfg
|
e.Cfg = cfg
|
||||||
e.logs = make(map[string]*InternalLogEntry)
|
e.logs = make(map[string]*InternalLogEntry)
|
||||||
e.logsTrackingIdsByDate = make([]string, 0)
|
e.logsTrackingIdsByDate = make([]string, 0)
|
||||||
e.LogsByActionId = make(map[string][]*InternalLogEntry)
|
e.LogsByBindingId = make(map[string][]*InternalLogEntry)
|
||||||
e.MapActionIdToBinding = make(map[string]*ActionBinding)
|
e.MapActionBindings = make(map[string]*ActionBinding)
|
||||||
|
|
||||||
e.chainOfCommand = []executorStepFunc{
|
e.chainOfCommand = []executorStepFunc{
|
||||||
stepRequestAction,
|
stepRequestAction,
|
||||||
|
|
@ -236,12 +234,27 @@ func isLogEntryAllowedByACL(cfg *config.Config, user *authpublic.AuthenticatedUs
|
||||||
return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
|
return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.AuthenticatedUser) []*InternalLogEntry {
|
func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.AuthenticatedUser, dateFilter string) []*InternalLogEntry {
|
||||||
e.logmutex.RLock()
|
e.logmutex.RLock()
|
||||||
defer e.logmutex.RUnlock()
|
defer e.logmutex.RUnlock()
|
||||||
|
|
||||||
filtered := make([]*InternalLogEntry, 0, len(e.logsTrackingIdsByDate))
|
filtered := make([]*InternalLogEntry, 0, len(e.logsTrackingIdsByDate))
|
||||||
|
|
||||||
|
var filterDate time.Time
|
||||||
|
var hasDateFilter bool
|
||||||
|
if dateFilter != "" {
|
||||||
|
parsedDate, err := time.Parse("2006-01-02", dateFilter)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"dateFilter": dateFilter,
|
||||||
|
"error": err,
|
||||||
|
}).Errorf("Failed to parse date filter, expected format YYYY-MM-DD")
|
||||||
|
} else {
|
||||||
|
filterDate = parsedDate
|
||||||
|
hasDateFilter = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, trackingId := range e.logsTrackingIdsByDate {
|
for _, trackingId := range e.logsTrackingIdsByDate {
|
||||||
entry := e.logs[trackingId]
|
entry := e.logs[trackingId]
|
||||||
|
|
||||||
|
|
@ -249,6 +262,13 @@ func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.Authenti
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isLogEntryAllowedByACL(cfg, user, entry) {
|
if isLogEntryAllowedByACL(cfg, user, entry) {
|
||||||
|
if hasDateFilter {
|
||||||
|
entryDate := entry.DatetimeStarted.UTC().Truncate(24 * time.Hour)
|
||||||
|
filterDateUTC := filterDate.UTC().Truncate(24 * time.Hour)
|
||||||
|
if !entryDate.Equal(filterDateUTC) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
filtered = append(filtered, entry)
|
filtered = append(filtered, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -282,8 +302,9 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC
|
||||||
|
|
||||||
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
|
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
|
||||||
// paginated correctly based on the filtered set.
|
// paginated correctly based on the filtered set.
|
||||||
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
|
// dateFilter is optional and should be in YYYY-MM-DD format. If empty, no date filtering is applied.
|
||||||
filtered := e.filterLogsByACL(cfg, user)
|
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64, dateFilter string) ([]*InternalLogEntry, *PagingResult) {
|
||||||
|
filtered := e.filterLogsByACL(cfg, user, dateFilter)
|
||||||
return paginateFilteredLogs(filtered, startOffset, pageCount)
|
return paginateFilteredLogs(filtered, startOffset, pageCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,10 +318,10 @@ func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
|
||||||
return entry, found
|
return entry, found
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
|
func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry {
|
||||||
e.logmutex.RLock()
|
e.logmutex.RLock()
|
||||||
|
|
||||||
logs, found := e.LogsByActionId[actionId]
|
logs, found := e.LogsByBindingId[bindingId]
|
||||||
|
|
||||||
e.logmutex.RUnlock()
|
e.logmutex.RUnlock()
|
||||||
|
|
||||||
|
|
@ -311,6 +332,122 @@ func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
|
||||||
return logs
|
return logs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldCountExecution checks if a log entry should be counted for rate limiting.
|
||||||
|
func shouldCountExecution(logEntry *InternalLogEntry, windowStart time.Time) bool {
|
||||||
|
return !logEntry.Blocked && logEntry.DatetimeStarted.After(windowStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOldestExecution updates the oldest execution time if this entry is older.
|
||||||
|
func updateOldestExecution(oldestExecutionTime **time.Time, logEntry *InternalLogEntry) {
|
||||||
|
if *oldestExecutionTime == nil {
|
||||||
|
*oldestExecutionTime = &logEntry.DatetimeStarted
|
||||||
|
} else if logEntry.DatetimeStarted.Before(**oldestExecutionTime) {
|
||||||
|
*oldestExecutionTime = &logEntry.DatetimeStarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOldestExecutionInWindow finds the oldest execution within the time window and counts executions.
|
||||||
|
// Returns the count of executions and the oldest execution time, or nil if none found.
|
||||||
|
func findOldestExecutionInWindow(logs []*InternalLogEntry, windowStart time.Time) (int, *time.Time) {
|
||||||
|
executions := 0
|
||||||
|
var oldestExecutionTime *time.Time
|
||||||
|
|
||||||
|
for _, logEntry := range logs {
|
||||||
|
if !shouldCountExecution(logEntry, windowStart) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
executions++
|
||||||
|
updateOldestExecution(&oldestExecutionTime, logEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return executions, oldestExecutionTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateExpiryTime calculates when the oldest execution will fall outside the rate limit window.
|
||||||
|
func calculateExpiryTime(oldestExecutionTime time.Time, duration time.Duration, now time.Time) time.Time {
|
||||||
|
expiryTime := oldestExecutionTime.Add(duration)
|
||||||
|
if !expiryTime.After(now) {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return expiryTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMaxExpiryTime updates maxExpiryTime if expiryTime is later.
|
||||||
|
func updateMaxExpiryTime(maxExpiryTime *time.Time, expiryTime time.Time) {
|
||||||
|
if expiryTime.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxExpiryTime.IsZero() || expiryTime.After(*maxExpiryTime) {
|
||||||
|
*maxExpiryTime = expiryTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateExpiryForRate calculates the expiry time for a single rate limit rule.
|
||||||
|
// Returns the expiry time if the rate limit is exceeded, or zero time if not.
|
||||||
|
func calculateExpiryForRate(rate config.RateSpec, logs []*InternalLogEntry, now time.Time) time.Time {
|
||||||
|
duration := parseDuration(rate)
|
||||||
|
if duration <= 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
windowStart := now.Add(-duration)
|
||||||
|
executions, oldestExecutionTime := findOldestExecutionInWindow(logs, windowStart)
|
||||||
|
|
||||||
|
if executions < rate.Limit || oldestExecutionTime == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateExpiryTime(*oldestExecutionTime, duration, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogsForBinding retrieves logs for a binding ID.
|
||||||
|
func (e *Executor) getLogsForBinding(bindingId string) []*InternalLogEntry {
|
||||||
|
e.logmutex.RLock()
|
||||||
|
logs, found := e.LogsByBindingId[bindingId]
|
||||||
|
e.logmutex.RUnlock()
|
||||||
|
|
||||||
|
if !found || len(logs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateMaxExpiryTimeFromRates calculates the maximum expiry time across all rate limit rules.
|
||||||
|
func calculateMaxExpiryTimeFromRates(rates []config.RateSpec, logs []*InternalLogEntry, now time.Time) time.Time {
|
||||||
|
var maxExpiryTime time.Time
|
||||||
|
|
||||||
|
for _, rate := range rates {
|
||||||
|
expiryTime := calculateExpiryForRate(rate, logs, now)
|
||||||
|
updateMaxExpiryTime(&maxExpiryTime, expiryTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxExpiryTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTimeUntilAvailable calculates when an action will be available again based on rate limits.
|
||||||
|
// Returns the Unix timestamp in seconds when the rate limit expires, or 0 if the action is available now.
|
||||||
|
func (e *Executor) GetTimeUntilAvailable(binding *ActionBinding) int64 {
|
||||||
|
if len(binding.Action.MaxRate) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := e.getLogsForBinding(binding.ID)
|
||||||
|
if logs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
maxExpiryTime := calculateMaxExpiryTimeFromRates(binding.Action.MaxRate, logs, time.Now())
|
||||||
|
|
||||||
|
if maxExpiryTime.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxExpiryTime.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) {
|
func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) {
|
||||||
e.logmutex.Lock()
|
e.logmutex.Lock()
|
||||||
|
|
||||||
|
|
@ -337,7 +474,6 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
|
||||||
ExitCode: DefaultExitCodeNotExecuted,
|
ExitCode: DefaultExitCodeNotExecuted,
|
||||||
ExecutionStarted: false,
|
ExecutionStarted: false,
|
||||||
ExecutionFinished: false,
|
ExecutionFinished: false,
|
||||||
ActionId: "",
|
|
||||||
ActionTitle: "notfound",
|
ActionTitle: "notfound",
|
||||||
ActionIcon: "💩",
|
ActionIcon: "💩",
|
||||||
Username: req.AuthenticatedUser.Username,
|
Username: req.AuthenticatedUser.Username,
|
||||||
|
|
@ -374,6 +510,11 @@ func (e *Executor) execChain(req *ExecutionRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure DatetimeFinished is set even if execution was blocked early
|
||||||
|
if req.logEntry.DatetimeFinished.IsZero() {
|
||||||
|
req.logEntry.DatetimeFinished = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
req.logEntry.ExecutionFinished = true
|
req.logEntry.ExecutionFinished = true
|
||||||
|
|
||||||
// This isn't a step, because we want to notify all listeners, irrespective
|
// This isn't a step, because we want to notify all listeners, irrespective
|
||||||
|
|
@ -386,7 +527,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
|
||||||
|
|
||||||
req.executor.logmutex.RLock()
|
req.executor.logmutex.RLock()
|
||||||
|
|
||||||
for _, log := range req.executor.GetLogsByActionId(req.Binding.Action.ID) {
|
for _, log := range req.executor.GetLogsByBindingId(req.Binding.ID) {
|
||||||
if !log.ExecutionFinished {
|
if !log.ExecutionFinished {
|
||||||
concurrentCount += 1
|
concurrentCount += 1
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +577,7 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
|
||||||
|
|
||||||
then := time.Now().Add(-duration)
|
then := time.Now().Add(-duration)
|
||||||
|
|
||||||
for _, logEntry := range req.executor.GetLogsByActionId(req.Binding.Action.ID) {
|
for _, logEntry := range req.executor.GetLogsByBindingId(req.Binding.ID) {
|
||||||
// FIXME
|
// FIXME
|
||||||
/*
|
/*
|
||||||
if logEntry.EntityPrefix != req.EntityPrefix {
|
if logEntry.EntityPrefix != req.EntityPrefix {
|
||||||
|
|
@ -573,16 +714,15 @@ func stepRequestAction(req *ExecutionRequest) bool {
|
||||||
req.logEntry.ActionConfigTitle = req.Binding.Action.Title
|
req.logEntry.ActionConfigTitle = req.Binding.Action.Title
|
||||||
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
|
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
|
||||||
req.logEntry.ActionIcon = req.Binding.Action.Icon
|
req.logEntry.ActionIcon = req.Binding.Action.Icon
|
||||||
req.logEntry.ActionId = req.Binding.Action.ID
|
|
||||||
req.logEntry.Tags = req.Tags
|
req.logEntry.Tags = req.Tags
|
||||||
|
|
||||||
req.executor.logmutex.Lock()
|
req.executor.logmutex.Lock()
|
||||||
|
|
||||||
if _, containsKey := req.executor.LogsByActionId[req.Binding.Action.ID]; !containsKey {
|
if _, containsKey := req.executor.LogsByBindingId[req.Binding.ID]; !containsKey {
|
||||||
req.executor.LogsByActionId[req.Binding.Action.ID] = make([]*InternalLogEntry, 0)
|
req.executor.LogsByBindingId[req.Binding.ID] = make([]*InternalLogEntry, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.executor.LogsByActionId[req.Binding.Action.ID] = append(req.executor.LogsByActionId[req.Binding.Action.ID], req.logEntry)
|
req.executor.LogsByBindingId[req.Binding.ID] = append(req.executor.LogsByBindingId[req.Binding.ID], req.logEntry)
|
||||||
|
|
||||||
req.executor.logmutex.Unlock()
|
req.executor.logmutex.Unlock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e *Executor) FindBindingByID(id string) *ActionBinding {
|
func (e *Executor) FindBindingByID(id string) *ActionBinding {
|
||||||
e.MapActionIdToBindingLock.RLock()
|
e.MapActionBindingsLock.RLock()
|
||||||
pair, found := e.MapActionIdToBinding[id]
|
pair, found := e.MapActionBindings[id]
|
||||||
e.MapActionIdToBindingLock.RUnlock()
|
e.MapActionBindingsLock.RUnlock()
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -23,11 +23,11 @@ func (e *Executor) FindBindingByID(id string) *ActionBinding {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding {
|
func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding {
|
||||||
e.MapActionIdToBindingLock.RLock()
|
e.MapActionBindingsLock.RLock()
|
||||||
|
|
||||||
defer e.MapActionIdToBindingLock.RUnlock()
|
defer e.MapActionBindingsLock.RUnlock()
|
||||||
|
|
||||||
for _, binding := range e.MapActionIdToBinding {
|
for _, binding := range e.MapActionBindings {
|
||||||
if binding.Action == action && binding.Entity == nil {
|
if binding.Action == action && binding.Entity == nil {
|
||||||
return binding
|
return binding
|
||||||
}
|
}
|
||||||
|
|
@ -42,9 +42,9 @@ type RebuildActionMapRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) RebuildActionMap() {
|
func (e *Executor) RebuildActionMap() {
|
||||||
e.MapActionIdToBindingLock.Lock()
|
e.MapActionBindingsLock.Lock()
|
||||||
|
|
||||||
clear(e.MapActionIdToBinding)
|
clear(e.MapActionBindings)
|
||||||
|
|
||||||
req := &RebuildActionMapRequest{
|
req := &RebuildActionMapRequest{
|
||||||
Cfg: e.Cfg,
|
Cfg: e.Cfg,
|
||||||
|
|
@ -65,7 +65,7 @@ func (e *Executor) RebuildActionMap() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.MapActionIdToBindingLock.Unlock()
|
e.MapActionBindingsLock.Unlock()
|
||||||
|
|
||||||
for _, l := range e.listeners {
|
for _, l := range e.listeners {
|
||||||
l.OnActionMapRebuilt()
|
l.OnActionMapRebuilt()
|
||||||
|
|
@ -100,10 +100,10 @@ func recurseDashboardForActionTitles(component *config.DashboardComponent, req *
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
|
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
|
||||||
actionId := hashActionToID(action, "")
|
bindingId := generateActionBindingId(action, "")
|
||||||
|
|
||||||
e.MapActionIdToBinding[actionId] = &ActionBinding{
|
e.MapActionBindings[bindingId] = &ActionBinding{
|
||||||
ID: actionId,
|
ID: bindingId,
|
||||||
Action: action,
|
Action: action,
|
||||||
Entity: nil,
|
Entity: nil,
|
||||||
ConfigOrder: configOrder,
|
ConfigOrder: configOrder,
|
||||||
|
|
@ -118,9 +118,9 @@ func registerActionsFromEntities(e *Executor, configOrder int, entityTitle strin
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, ent *entities.Entity, req *RebuildActionMapRequest) {
|
func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, ent *entities.Entity, req *RebuildActionMapRequest) {
|
||||||
virtualActionId := hashActionToID(tpl, ent.UniqueKey)
|
virtualActionId := generateActionBindingId(tpl, ent.UniqueKey)
|
||||||
|
|
||||||
e.MapActionIdToBinding[virtualActionId] = &ActionBinding{
|
e.MapActionBindings[virtualActionId] = &ActionBinding{
|
||||||
ID: virtualActionId,
|
ID: virtualActionId,
|
||||||
Action: tpl,
|
Action: tpl,
|
||||||
Entity: ent,
|
Entity: ent,
|
||||||
|
|
@ -129,7 +129,7 @@ func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashActionToID(action *config.Action, entityPrefix string) string {
|
func generateActionBindingId(action *config.Action, entityPrefix string) string {
|
||||||
if action.ID != "" && entityPrefix == "" {
|
if action.ID != "" && entityPrefix == "" {
|
||||||
return action.ID
|
return action.ID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"github.com/OliveTin/OliveTin/internal/auth/otoauth2"
|
"github.com/OliveTin/OliveTin/internal/auth/otoauth2"
|
||||||
config "github.com/OliveTin/OliveTin/internal/config"
|
config "github.com/OliveTin/OliveTin/internal/config"
|
||||||
"github.com/OliveTin/OliveTin/internal/executor"
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/webhooks"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -72,6 +73,10 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
|
||||||
|
|
||||||
mux.HandleFunc("/readyz", handleReadyz)
|
mux.HandleFunc("/readyz", handleReadyz)
|
||||||
|
|
||||||
|
webhookHandler := webhooks.NewWebhookHandler(cfg, ex)
|
||||||
|
mux.HandleFunc("/webhooks", webhookHandler.HandleWebhook)
|
||||||
|
mux.HandleFunc("/webhooks/", webhookHandler.HandleWebhook)
|
||||||
|
|
||||||
webuiServer := NewWebUIServer(cfg)
|
webuiServer := NewWebUIServer(cfg)
|
||||||
|
|
||||||
mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
|
mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthVerifier struct {
|
||||||
|
config config.WebhookConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier {
|
||||||
|
return &AuthVerifier{config: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool {
|
||||||
|
verifier := v.getVerifier()
|
||||||
|
if verifier == nil {
|
||||||
|
return v.handleUnknownAuthType()
|
||||||
|
}
|
||||||
|
return verifier(r, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
type authVerifierFunc func(*http.Request, []byte) bool
|
||||||
|
|
||||||
|
func (v *AuthVerifier) getVerifier() authVerifierFunc {
|
||||||
|
if v.config.AuthType == "" || v.config.AuthType == "none" {
|
||||||
|
return func(_ *http.Request, _ []byte) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifierMap := v.buildVerifierMap()
|
||||||
|
if verifier, ok := verifierMap[v.config.AuthType]; ok {
|
||||||
|
return verifier
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) buildVerifierMap() map[string]authVerifierFunc {
|
||||||
|
return map[string]authVerifierFunc{
|
||||||
|
"hmac-sha256": v.verifyHMAC256,
|
||||||
|
"hmac-sha1": v.verifyHMAC1,
|
||||||
|
"bearer": func(r *http.Request, _ []byte) bool {
|
||||||
|
return v.verifyBearer(r)
|
||||||
|
},
|
||||||
|
"basic": func(r *http.Request, _ []byte) bool {
|
||||||
|
return v.verifyBasic(r)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) handleUnknownAuthType() bool {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"authType": v.config.AuthType,
|
||||||
|
}).Warnf("Unknown auth type, rejecting")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("HMAC-SHA256 auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
headerName := v.config.AuthHeader
|
||||||
|
if headerName == "" {
|
||||||
|
headerName = "X-Webhook-Signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := r.Header.Get(headerName)
|
||||||
|
if signature == "" {
|
||||||
|
log.Debugf("Missing signature header: %s", headerName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSig := strings.TrimPrefix(signature, "sha256=")
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(v.config.Secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
computedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return hmac.Equal([]byte(expectedSig), []byte(computedSig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyHMAC1(r *http.Request, payload []byte) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("HMAC-SHA1 auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
headerName := v.config.AuthHeader
|
||||||
|
if headerName == "" {
|
||||||
|
headerName = "X-Webhook-Signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := r.Header.Get(headerName)
|
||||||
|
if signature == "" {
|
||||||
|
log.Debugf("Missing signature header: %s", headerName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSig := strings.TrimPrefix(signature, "sha1=")
|
||||||
|
|
||||||
|
mac := hmac.New(sha1.New, []byte(v.config.Secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
computedSig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return hmac.Equal([]byte(expectedSig), []byte(computedSig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBearer(r *http.Request) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("Bearer auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
log.Debugf("Missing or invalid Bearer token")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
tokenBytes := []byte(token)
|
||||||
|
secretBytes := []byte(v.config.Secret)
|
||||||
|
return len(tokenBytes) == len(secretBytes) && subtle.ConstantTimeCompare(tokenBytes, secretBytes) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBasic(r *http.Request) bool {
|
||||||
|
if v.config.Secret == "" {
|
||||||
|
log.Warnf("Basic auth requires secret")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
log.Debugf("Missing Basic auth header")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.verifyBasicCredentials(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBasicCredentials(username, password string) bool {
|
||||||
|
parts := strings.SplitN(v.config.Secret, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return v.verifyBasicWithUsername(username, password, parts[0], parts[1])
|
||||||
|
}
|
||||||
|
return v.verifyBasicPasswordOnly(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBasicWithUsername(username, password, expectedUsername, expectedPassword string) bool {
|
||||||
|
usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername))
|
||||||
|
passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword))
|
||||||
|
return usernameMatch == 1 && passwordMatch == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *AuthVerifier) verifyBasicPasswordOnly(password string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyGitHubTemplate applies GitHub-specific template configurations
|
||||||
|
// This allows users to use simple template names instead of configuring everything manually
|
||||||
|
func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) {
|
||||||
|
applier := getTemplateApplier(template)
|
||||||
|
if applier != nil {
|
||||||
|
applier(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type templateApplier func(*config.WebhookConfig)
|
||||||
|
|
||||||
|
func getTemplateApplier(template string) templateApplier {
|
||||||
|
templateMap := map[string]templateApplier{
|
||||||
|
"github-push": applyGitHubPushTemplate,
|
||||||
|
"github-pr": applyGitHubPRTemplate,
|
||||||
|
"github-pull-request": applyGitHubPRTemplate,
|
||||||
|
"github-release": applyGitHubReleaseTemplate,
|
||||||
|
"github-workflow": applyGitHubWorkflowTemplate,
|
||||||
|
}
|
||||||
|
return templateMap[template]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultAuth(cfg *config.WebhookConfig) {
|
||||||
|
if cfg.AuthHeader == "" {
|
||||||
|
cfg.AuthHeader = "X-Hub-Signature-256"
|
||||||
|
}
|
||||||
|
if cfg.AuthType == "" {
|
||||||
|
cfg.AuthType = "hmac-sha256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMatchHeaders(cfg *config.WebhookConfig) {
|
||||||
|
if len(cfg.MatchHeaders) == 0 {
|
||||||
|
cfg.MatchHeaders = make(map[string]string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureExtract(cfg *config.WebhookConfig) {
|
||||||
|
if len(cfg.Extract) == 0 {
|
||||||
|
cfg.Extract = make(map[string]string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) {
|
||||||
|
if _, exists := cfg.Extract[key]; !exists {
|
||||||
|
cfg.Extract[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) {
|
||||||
|
if _, exists := cfg.MatchHeaders[key]; !exists {
|
||||||
|
cfg.MatchHeaders[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
|
||||||
|
setDefaultAuth(cfg)
|
||||||
|
ensureMatchHeaders(cfg)
|
||||||
|
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push")
|
||||||
|
ensureExtract(cfg)
|
||||||
|
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||||
|
setExtractIfMissing(cfg, "git_ref", "$.ref")
|
||||||
|
setExtractIfMissing(cfg, "git_commit", "$.head_commit.id")
|
||||||
|
setExtractIfMissing(cfg, "git_branch", "$.ref")
|
||||||
|
setExtractIfMissing(cfg, "git_message", "$.head_commit.message")
|
||||||
|
setExtractIfMissing(cfg, "git_author", "$.head_commit.author.name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
|
||||||
|
setDefaultAuth(cfg)
|
||||||
|
ensureMatchHeaders(cfg)
|
||||||
|
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request")
|
||||||
|
ensureExtract(cfg)
|
||||||
|
setExtractIfMissing(cfg, "pr_number", "$.number")
|
||||||
|
setExtractIfMissing(cfg, "pr_title", "$.pull_request.title")
|
||||||
|
setExtractIfMissing(cfg, "pr_author", "$.pull_request.user.login")
|
||||||
|
setExtractIfMissing(cfg, "pr_action", "$.action")
|
||||||
|
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||||
|
setExtractIfMissing(cfg, "pr_state", "$.pull_request.state")
|
||||||
|
setExtractIfMissing(cfg, "pr_head_sha", "$.pull_request.head.sha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
|
||||||
|
setDefaultAuth(cfg)
|
||||||
|
ensureMatchHeaders(cfg)
|
||||||
|
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release")
|
||||||
|
ensureExtract(cfg)
|
||||||
|
setExtractIfMissing(cfg, "release_action", "$.action")
|
||||||
|
setExtractIfMissing(cfg, "release_tag", "$.release.tag_name")
|
||||||
|
setExtractIfMissing(cfg, "release_name", "$.release.name")
|
||||||
|
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||||
|
setExtractIfMissing(cfg, "release_author", "$.release.author.login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) {
|
||||||
|
setDefaultAuth(cfg)
|
||||||
|
ensureMatchHeaders(cfg)
|
||||||
|
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run")
|
||||||
|
ensureExtract(cfg)
|
||||||
|
setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name")
|
||||||
|
setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status")
|
||||||
|
setExtractIfMissing(cfg, "workflow_conclusion", "$.workflow_run.conclusion")
|
||||||
|
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||||
|
setExtractIfMissing(cfg, "git_commit", "$.workflow_run.head_sha")
|
||||||
|
setExtractIfMissing(cfg, "git_branch", "$.workflow_run.head_branch")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/OliveTin/OliveTin/internal/auth"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
"github.com/OliveTin/OliveTin/internal/executor"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionWebhookConfig struct {
|
||||||
|
Action *config.Action
|
||||||
|
Config config.WebhookConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookHandler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
executor *executor.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebhookHandler(cfg *config.Config, ex *executor.Executor) *WebhookHandler {
|
||||||
|
return &WebhookHandler{
|
||||||
|
cfg: cfg,
|
||||||
|
executor: ex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := h.readPayload(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingActions := h.findMatchingActions(r, payload)
|
||||||
|
if len(matchingActions) == 0 {
|
||||||
|
h.writeOKResponse(w, "no matching webhook actions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := h.processMatchingActions(matchingActions, r, payload)
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"matched": len(matchingActions),
|
||||||
|
"processed": processed,
|
||||||
|
}).Infof("Webhook processed")
|
||||||
|
|
||||||
|
h.writeOKResponse(w, "webhook actions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) {
|
||||||
|
maxSize := int64(1024 * 1024)
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize))
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Failed to read webhook payload")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) writeOKResponse(w http.ResponseWriter, context string) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := w.Write([]byte("OK")); err != nil {
|
||||||
|
log.WithError(err).Warnf("Failed to write response for %s", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) processMatchingActions(matchingActions []ActionWebhookConfig, r *http.Request, payload []byte) int {
|
||||||
|
processed := 0
|
||||||
|
for _, actionConfig := range matchingActions {
|
||||||
|
if h.processWebhook(actionConfig, r, payload) {
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return processed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte) []ActionWebhookConfig {
|
||||||
|
var matches []ActionWebhookConfig
|
||||||
|
|
||||||
|
for _, action := range h.cfg.Actions {
|
||||||
|
matches = append(matches, h.findMatchingWebhooksForAction(action, r, payload)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) findMatchingWebhooksForAction(action *config.Action, r *http.Request, payload []byte) []ActionWebhookConfig {
|
||||||
|
var matches []ActionWebhookConfig
|
||||||
|
|
||||||
|
for _, webhookConfig := range action.ExecOnWebhook {
|
||||||
|
webhookConfigCopy := webhookConfig
|
||||||
|
|
||||||
|
if webhookConfigCopy.Template != "" {
|
||||||
|
ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template)
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := NewWebhookMatcher(webhookConfigCopy, r, payload)
|
||||||
|
if matcher.Matches() {
|
||||||
|
matches = append(matches, ActionWebhookConfig{
|
||||||
|
Action: action,
|
||||||
|
Config: webhookConfigCopy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *http.Request, payload []byte) bool {
|
||||||
|
verifier := NewAuthVerifier(actionConfig.Config)
|
||||||
|
if !verifier.Verify(r, payload) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": actionConfig.Action.Title,
|
||||||
|
"authType": actionConfig.Config.AuthType,
|
||||||
|
}).Warnf("Webhook authentication failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher := NewWebhookMatcher(actionConfig.Config, r, payload)
|
||||||
|
|
||||||
|
args, err := matcher.ExtractArguments()
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": actionConfig.Action.Title,
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Failed to extract webhook arguments")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
h.executeAction(actionConfig.Action, args)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) {
|
||||||
|
binding := h.executor.FindBindingWithNoEntity(action)
|
||||||
|
if binding == nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"actionTitle": action.Title,
|
||||||
|
}).Warnf("Action binding not found, skipping execution")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &executor.ExecutionRequest{
|
||||||
|
Binding: binding,
|
||||||
|
Cfg: h.cfg,
|
||||||
|
Tags: []string{"webhook"},
|
||||||
|
Arguments: args,
|
||||||
|
AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.executor.ExecRequest(req)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/PaesslerAG/jsonpath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JSONMatcher struct {
|
||||||
|
payload interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJSONMatcher(payload []byte) (*JSONMatcher, error) {
|
||||||
|
var data interface{}
|
||||||
|
if err := json.Unmarshal(payload, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &JSONMatcher{payload: data}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, error) {
|
||||||
|
value, err := jsonpath.Get(pathExpr, m.payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON for consistent string representation
|
||||||
|
jsonBytes, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to marshal extracted value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valueStr := string(jsonBytes)
|
||||||
|
return valueStr == expectedValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
|
||||||
|
value, err := jsonpath.Get(pathExpr, m.payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON for consistent string representation
|
||||||
|
jsonBytes, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal extracted value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JSONMatcher) GetPayload() interface{} {
|
||||||
|
return m.payload
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
package webhooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/OliveTin/OliveTin/internal/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebhookMatcher struct {
|
||||||
|
config config.WebhookConfig
|
||||||
|
req *http.Request
|
||||||
|
bodyBytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte) *WebhookMatcher {
|
||||||
|
return &WebhookMatcher{
|
||||||
|
config: cfg,
|
||||||
|
req: r,
|
||||||
|
bodyBytes: bodyBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) Matches() bool {
|
||||||
|
if !m.matchHeaders() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.matchQuery() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.matchPath() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchHeaders() bool {
|
||||||
|
if len(m.config.MatchHeaders) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, expectedValue := range m.config.MatchHeaders {
|
||||||
|
actualValue := m.req.Header.Get(key)
|
||||||
|
if !m.compareValues(actualValue, expectedValue) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"header": key,
|
||||||
|
"expected": expectedValue,
|
||||||
|
"actual": actualValue,
|
||||||
|
}).Debugf("Header mismatch")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchQuery() bool {
|
||||||
|
if len(m.config.MatchQuery) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
query := m.req.URL.Query()
|
||||||
|
for key, expectedValue := range m.config.MatchQuery {
|
||||||
|
actualValue := query.Get(key)
|
||||||
|
if !m.compareValues(actualValue, expectedValue) {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"query": key,
|
||||||
|
"expected": expectedValue,
|
||||||
|
"actual": actualValue,
|
||||||
|
}).Debugf("Query parameter mismatch")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchPath() bool {
|
||||||
|
if m.config.MatchPath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonPath, expectedValue := m.parseMatchPath()
|
||||||
|
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Failed to create JSON matcher")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.matchPathValue(matcher, jsonPath, expectedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) parseMatchPath() (string, string) {
|
||||||
|
parts := strings.SplitN(m.config.MatchPath, "=", 2)
|
||||||
|
jsonPath := parts[0]
|
||||||
|
expectedValue := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
expectedValue = parts[1]
|
||||||
|
}
|
||||||
|
return jsonPath, expectedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expectedValue string) bool {
|
||||||
|
if expectedValue == "" {
|
||||||
|
_, err := matcher.ExtractValue(jsonPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := matcher.MatchPath(jsonPath, expectedValue)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"jsonPath": jsonPath,
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Failed to match JSONPath")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) compareValues(actual, expected string) bool {
|
||||||
|
if strings.HasPrefix(expected, "regex:") {
|
||||||
|
pattern := strings.TrimPrefix(expected, "regex:")
|
||||||
|
matched, err := regexp.MatchString(pattern, actual)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"pattern": pattern,
|
||||||
|
"error": err,
|
||||||
|
}).Warnf("Invalid regex pattern")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
return actual == expected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
|
||||||
|
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := m.extractJSONPathValues(matcher)
|
||||||
|
m.addWebhookMetadata(args)
|
||||||
|
m.addWebhookHeaders(args)
|
||||||
|
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) extractJSONPathValues(matcher *JSONMatcher) map[string]string {
|
||||||
|
args := make(map[string]string)
|
||||||
|
|
||||||
|
for argName, jsonPath := range m.config.Extract {
|
||||||
|
value, err := matcher.ExtractValue(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"argName": argName,
|
||||||
|
"jsonPath": jsonPath,
|
||||||
|
"error": err,
|
||||||
|
}).Debugf("Failed to extract value")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args[argName] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) addWebhookMetadata(args map[string]string) {
|
||||||
|
args["webhook_method"] = m.req.Method
|
||||||
|
args["webhook_path"] = m.req.URL.Path
|
||||||
|
args["webhook_query"] = m.req.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebhookMatcher) addWebhookHeaders(args map[string]string) {
|
||||||
|
for key, values := range m.req.Header {
|
||||||
|
if len(values) > 0 {
|
||||||
|
args["webhook_header_"+strings.ToLower(key)] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue