Merge branch 'beta' of github.com:OliveTin/OliveTin into beta

This commit is contained in:
jamesread 2026-01-11 09:16:07 +00:00
commit d44ba5f18e
50 changed files with 6964 additions and 294 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
- name: "Living Room Light"
powered_on: false
- name: "Bedroom Light"
powered_on: true

View File

@ -1,5 +1,7 @@
logLevel: debug logLevel: debug
insecureAllowDumpActionMap: true
actions: actions:
- title: Check status - title: Check status
id: status_command id: status_command

View File

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

View File

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

View File

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

View File

@ -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": "退出代码",

View File

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

View File

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

View File

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

View File

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

View File

@ -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: 在哪里找到帮助

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "&#x1f4a9;", ActionIcon: "&#x1f4a9;",
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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