feat: block notifications, and cleanup of action/binding IDs

This commit is contained in:
jamesread 2026-01-11 01:10:20 +00:00
parent 499bcdc827
commit 6493f7bee7
17 changed files with 456 additions and 189 deletions

View File

@ -50,7 +50,7 @@ actions:
popupOnStart: execution-button
maxRate:
- limit: 3
duration: 5m
duration: 1m
# You are not limited to operating system commands, and of course you can run
# 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 { rateLimits } from '../resources/vue/stores/rateLimits.js'
export function initWebsocket () {
window.addEventListener('EventOutputChunk', onOutputChunk)
window.addEventListener('EventExecutionStarted', onExecutionChanged)
window.addEventListener('EventExecutionFinished', onExecutionChanged)
reconnectWebsocket()
}
@ -40,7 +43,6 @@ function handleEvent (msg) {
break
case 'EventExecutionFinished':
case 'EventExecutionStarted':
buttonResults[msg.event.value.logEntry.executionTrackingId] = msg.event.value.logEntry
window.dispatchEvent(j)
break
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

@ -53,6 +53,13 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
* @generated from field: int32 timeout = 8;
*/
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;
};
/**
@ -548,11 +555,6 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
*/
datetimeFinished: string;
/**
* @generated from field: string action_id = 13;
*/
actionId: string;
/**
* @generated from field: bool execution_started = 14;
*/
@ -577,6 +579,20 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
* @generated from field: bool can_kill = 18;
*/
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;
};
/**
@ -909,9 +925,9 @@ export declare type DumpVarsResponse = Message<"olivetin.api.v1.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;
*/
@ -924,10 +940,10 @@ export declare type ActionEntityPair = Message<"olivetin.api.v1.ActionEntityPair
};
/**
* Describes the message olivetin.api.v1.ActionEntityPair.
* Use `create(ActionEntityPairSchema)` to create a new message.
* Describes the message olivetin.api.v1.DebugBinding.
* 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
@ -951,9 +967,9 @@ export declare type DumpPublicIdActionMapResponse = Message<"olivetin.api.v1.Dum
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,6 +1,6 @@
<template>
<div :id="`actionButton-${actionId}`" role="none" class="action-button">
<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
<div :id="`actionButton-${bindingId}`" role="none" class="action-button">
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
:class="combinedClasses" @click="handleClick">
<div class="navigate-on-start-container">
@ -18,18 +18,19 @@
<span class="icon" v-html="unicodeIcon"></span>
<span class="title" aria-live="polite">{{ displayTitle }}
</span>
<span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
</button>
</div>
</template>
<script setup>
import ArgumentForm from './views/ArgumentForm.vue'
import { buttonResults } from './stores/buttonResults'
import { rateLimits } from './stores/rateLimits'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
import { ref, watch, onMounted, inject, computed } from 'vue'
import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
const router = useRouter()
const navigateOnStart = ref('')
@ -46,7 +47,7 @@ const props = defineProps({
}
})
const actionId = ref('')
const bindingId = ref('')
const title = ref('')
const canExec = ref(true)
const popupOnStart = ref('')
@ -59,6 +60,12 @@ const displayTitle = ref('')
const isDisabled = ref(false)
const showArgumentForm = ref(false)
// Rate limiting
const rateLimitExpires = ref(0)
const isRateLimited = ref(false)
const rateLimitMessage = ref('')
let rateLimitInterval = null
// Animation classes
const buttonClasses = ref([])
@ -89,7 +96,7 @@ function constructFromJson(json) {
updateFromJson(json)
actionId.value = json.bindingId
bindingId.value = json.bindingId
title.value = json.title
canExec.value = json.canExec
popupOnStart.value = json.popupOnStart
@ -103,6 +110,19 @@ function constructFromJson(json) {
isDisabled.value = !json.canExec
displayTitle.value = title.value
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) {
@ -110,6 +130,55 @@ function updateFromJson(json) {
// title - as the callback URL relies on it
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() {
@ -213,6 +282,30 @@ function onExecStatusChanged() {
onMounted(() => {
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(
@ -270,6 +363,13 @@ watch(
padding: 0.2em;
}
.action-button button .rate-limit-message {
font-size: 0.75em;
color: #856404;
padding: 0.2em;
font-weight: normal;
}
/* Animation classes */
.action-button button.action-timeout {
background: #fff3cd;

View File

@ -12,7 +12,8 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { buttonResults } from '../stores/buttonResults'
const props = defineProps({
component: {
@ -23,7 +24,20 @@ const props = defineProps({
const output = ref('Waiting...')
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() {
if (!props.component.title) {
@ -46,14 +60,7 @@ async function fetchMostRecentExecution() {
const result = await window.client.executionStatus(executionStatusArgs)
if (result.logEntry) {
if (result.logEntry.output !== undefined) {
output.value = result.logEntry.output
} else {
output.value = 'No output available'
}
if (result.logEntry.executionTrackingId) {
executionTrackingId.value = result.logEntry.executionTrackingId
}
updateFromLogEntry(result.logEntry)
} else {
output.value = 'No output available'
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(() => {
fetchMostRecentExecution()
eventListener = (event) => handleExecutionFinished(event)
window.addEventListener('EventExecutionFinished', eventListener)
unwatchButtonResults = watch(
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(() => {
if (eventListener) {
window.removeEventListener('EventExecutionFinished', eventListener)
if (unwatchButtonResults) {
unwatchButtonResults()
}
})
</script>

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

@ -1,5 +1,7 @@
logLevel: debug
insecureAllowDumpActionMap: true
actions:
- title: Check status
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 () {
this.timeout(30000) // Increase timeout for this test
this.timeout(45000)
await getRootAndWait()
// Wait for the mre-output element
@ -105,7 +104,7 @@ describe('config: stdout-most-recent-execution', function () {
await statusButton.click()
// 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)
// We'll wait for the output to change from the initial state

View File

@ -13,6 +13,7 @@ message Action {
string popup_on_start = 6;
int32 order = 7;
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 {
@ -133,12 +134,13 @@ message LogEntry {
repeated string tags = 10;
string execution_tracking_id = 11;
string datetime_finished = 12;
string action_id = 13;
bool execution_started = 14;
bool execution_finished = 15;
bool blocked = 16;
int64 datetime_index = 17;
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 {
@ -216,7 +218,7 @@ message DumpVarsResponse {
map<string, string> contents = 2;
}
message ActionEntityPair {
message DebugBinding {
string action_title = 1;
string entity_prefix = 2;
}
@ -224,7 +226,7 @@ message ActionEntityPair {
message DumpPublicIdActionMapRequest {}
message DumpPublicIdActionMapResponse {
string alert = 1;
map<string, ActionEntityPair> contents = 2;
map<string, DebugBinding> contents = 2;
}
message GetReadyzRequest {}

View File

@ -31,6 +31,7 @@ type Action struct {
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"`
Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"`
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"
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -121,6 +122,13 @@ func (x *Action) GetTimeout() int32 {
return 0
}
func (x *Action) GetDatetimeRateLimitExpires() string {
if x != nil {
return x.DatetimeRateLimitExpires
}
return ""
}
type ActionArgument struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@ -1150,12 +1158,13 @@ type LogEntry struct {
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"`
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"`
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"`
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"`
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"
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
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1267,13 +1276,6 @@ func (x *LogEntry) GetDatetimeFinished() string {
return ""
}
func (x *LogEntry) GetActionId() string {
if x != nil {
return x.ActionId
}
return ""
}
func (x *LogEntry) GetExecutionStarted() bool {
if x != nil {
return x.ExecutionStarted
@ -1309,6 +1311,20 @@ func (x *LogEntry) GetCanKill() bool {
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 {
state protoimpl.MessageState `protogen:"open.v1"`
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
@ -2097,7 +2113,7 @@ func (x *DumpVarsResponse) GetContents() map[string]string {
return nil
}
type ActionEntityPair struct {
type DebugBinding struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
@ -2105,20 +2121,20 @@ type ActionEntityPair struct {
sizeCache protoimpl.SizeCache
}
func (x *ActionEntityPair) Reset() {
*x = ActionEntityPair{}
func (x *DebugBinding) Reset() {
*x = DebugBinding{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActionEntityPair) String() string {
func (x *DebugBinding) String() string {
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]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@ -2130,19 +2146,19 @@ func (x *ActionEntityPair) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use ActionEntityPair.ProtoReflect.Descriptor instead.
func (*ActionEntityPair) Descriptor() ([]byte, []int) {
// Deprecated: Use DebugBinding.ProtoReflect.Descriptor instead.
func (*DebugBinding) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35}
}
func (x *ActionEntityPair) GetActionTitle() string {
func (x *DebugBinding) GetActionTitle() string {
if x != nil {
return x.ActionTitle
}
return ""
}
func (x *ActionEntityPair) GetEntityPrefix() string {
func (x *DebugBinding) GetEntityPrefix() string {
if x != nil {
return x.EntityPrefix
}
@ -2188,7 +2204,7 @@ func (*DumpPublicIdActionMapRequest) Descriptor() ([]byte, []int) {
type DumpPublicIdActionMapResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
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
sizeCache protoimpl.SizeCache
}
@ -2230,7 +2246,7 @@ func (x *DumpPublicIdActionMapResponse) GetAlert() string {
return ""
}
func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*ActionEntityPair {
func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*DebugBinding {
if x != nil {
return x.Contents
}
@ -3847,7 +3863,7 @@ var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor
const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\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" +
"\n" +
"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" +
@ -3857,7 +3873,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\targuments\x18\x05 \x03(\v2\x1f.olivetin.api.v1.ActionArgumentR\targuments\x12$\n" +
"\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" +
"\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" +
"\atimeout\x18\b \x01(\x05R\atimeout\"\xa2\x03\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" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@ -3933,7 +3950,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\x1fStartActionByGetAndWaitResponse\x126\n" +
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"3\n" +
"\x0eGetLogsRequest\x12!\n" +
"\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\"\xc8\x04\n" +
"\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\"\x89\x05\n" +
"\bLogEntry\x12)\n" +
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
@ -3948,13 +3965,15 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\x04tags\x18\n" +
" \x03(\tR\x04tags\x122\n" +
"\x15execution_tracking_id\x18\v \x01(\tR\x13executionTrackingId\x12+\n" +
"\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12\x1b\n" +
"\taction_id\x18\r \x01(\tR\bactionId\x12+\n" +
"\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12+\n" +
"\x11execution_started\x18\x0e \x01(\bR\x10executionStarted\x12-\n" +
"\x12execution_finished\x18\x0f \x01(\bR\x11executionFinished\x12\x18\n" +
"\ablocked\x18\x10 \x01(\bR\ablocked\x12%\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" +
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
@ -4006,17 +4025,17 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\bcontents\x18\x02 \x03(\v2/.olivetin.api.v1.DumpVarsResponse.ContentsEntryR\bcontents\x1a;\n" +
"\rContentsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Z\n" +
"\x10ActionEntityPair\x12!\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"V\n" +
"\fDebugBinding\x12!\n" +
"\faction_title\x18\x01 \x01(\tR\vactionTitle\x12#\n" +
"\rentity_prefix\x18\x02 \x01(\tR\fentityPrefix\"\x1e\n" +
"\x1cDumpPublicIdActionMapRequest\"\xef\x01\n" +
"\x1cDumpPublicIdActionMapRequest\"\xeb\x01\n" +
"\x1dDumpPublicIdActionMapResponse\x12\x14\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" +
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
"\x05value\x18\x02 \x01(\v2!.olivetin.api.v1.ActionEntityPairR\x05value:\x028\x01\"\x12\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x123\n" +
"\x05value\x18\x02 \x01(\v2\x1d.olivetin.api.v1.DebugBindingR\x05value:\x028\x01\"\x12\n" +
"\x10GetReadyzRequest\"+\n" +
"\x11GetReadyzResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" +
@ -4190,7 +4209,7 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
(*SosReportResponse)(nil), // 32: olivetin.api.v1.SosReportResponse
(*DumpVarsRequest)(nil), // 33: olivetin.api.v1.DumpVarsRequest
(*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
(*DumpPublicIdActionMapResponse)(nil), // 37: olivetin.api.v1.DumpPublicIdActionMapResponse
(*GetReadyzRequest)(nil), // 38: olivetin.api.v1.GetReadyzRequest
@ -4259,7 +4278,7 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
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
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
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

View File

@ -16,6 +16,7 @@ import (
"fmt"
"net/http"
"sync"
"time"
acl "github.com/OliveTin/OliveTin/internal/acl"
auth "github.com/OliveTin/OliveTin/internal/auth"
@ -279,7 +280,6 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
pble := &apiv1.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
DatetimeIndex: logEntry.Index,
@ -298,6 +298,19 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, logEntry.Binding.Action)
}
// Calculate rate limit expiry for the action
if logEntry.Binding != nil && logEntry.Binding.Action != nil {
pble.BindingId = logEntry.Binding.ID
expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
if expiryUnix > 0 {
pble.DatetimeRateLimitExpires = time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
} else {
pble.DatetimeRateLimitExpires = ""
}
}
return pble
}
@ -311,10 +324,20 @@ func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string
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
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 {
return nil
@ -341,7 +364,7 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
} else {
ile = getMostRecentExecutionStatusById(api, req.Msg.ActionId)
ile = getMostRecentExecutionStatusByActionId(api, req.Msg.ActionId)
}
if ile == nil {
@ -539,7 +562,7 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
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)
if page.empty {
return connect.NewResponse(buildEmptyPageResponse(page)), nil
@ -688,7 +711,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) {
res := &apiv1.DumpPublicIdActionMapResponse{}
res.Contents = make(map[string]*apiv1.ActionEntityPair)
res.Contents = make(map[string]*apiv1.DebugBinding)
if !api.cfg.InsecureAllowDumpActionMap {
res.Alert = "Dumping Public IDs is disallowed."
@ -696,16 +719,15 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ
return connect.NewResponse(res), nil
}
api.executor.MapActionIdToBindingLock.RLock()
api.executor.MapActionBindingsLock.RLock()
for k, v := range api.executor.MapActionIdToBinding {
res.Contents[k] = &apiv1.ActionEntityPair{
for k, v := range api.executor.MapActionBindings {
res.Contents[k] = &apiv1.DebugBinding{
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"
@ -813,7 +835,7 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
}
}
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
@ -821,7 +843,7 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionFinished{
ExecutionFinished: &apiv1.EventExecutionFinished{
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
},
},
}:

View File

@ -3,6 +3,7 @@ package api
import (
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
@ -27,10 +28,10 @@ func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
}
func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
rr.ex.MapActionIdToBindingLock.RLock()
defer rr.ex.MapActionIdToBindingLock.RUnlock()
rr.ex.MapActionBindingsLock.RLock()
defer rr.ex.MapActionBindingsLock.RUnlock()
for _, binding := range rr.ex.MapActionIdToBinding {
for _, binding := range rr.ex.MapActionBindings {
if binding.Action.Title != title {
continue
}
@ -110,6 +111,13 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
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{
BindingId: actionBinding.ID,
Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity),
@ -118,6 +126,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
PopupOnStart: action.PopupOnStart,
Order: int32(actionBinding.ConfigOrder),
Timeout: int32(action.Timeout),
DatetimeRateLimitExpires: datetimeRateLimitExpires,
}
for _, cfgArg := range action.Arguments {

View File

@ -21,7 +21,7 @@ import (
"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.RebuildActionMap()
@ -63,7 +63,7 @@ func TestGetActionsAndStart(t *testing.T) {
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
conn, client := getNewTestServerAndClient(t, cfg)
conn, client := getNewTestServerAndClient(cfg)
respInit, errInit := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
respGetReady, errReady := client.GetReadyz(context.Background(), connect.NewRequest(&apiv1.GetReadyzRequest{}))
@ -99,7 +99,7 @@ func TestGetActionsAndStart(t *testing.T) {
func TestGetEntities(t *testing.T) {
cfg := config.DefaultConfig()
ts, client := getNewTestServerAndClient(t, cfg)
ts, client := getNewTestServerAndClient(cfg)
defer ts.Close()
setupTestEntities()
@ -315,10 +315,10 @@ func TestBuildActionWithEnabledExpression(t *testing.T) {
}
func findBindingByTitle(ex *executor.Executor, title string) *executor.ActionBinding {
ex.MapActionIdToBindingLock.RLock()
defer ex.MapActionIdToBindingLock.RUnlock()
ex.MapActionBindingsLock.RLock()
defer ex.MapActionBindingsLock.RUnlock()
for _, b := range ex.MapActionIdToBinding {
for _, b := range ex.MapActionBindings {
if b.Action.Title == title {
return b
}

View File

@ -128,7 +128,7 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
Contents: make([]*apiv1.DashboardComponent, 0),
}
for _, binding := range rr.ex.MapActionIdToBinding {
for _, binding := range rr.ex.MapActionBindings {
if binding.Action.Hidden {
continue
}

View File

@ -49,12 +49,12 @@ type ActionBinding struct {
type Executor struct {
logs map[string]*InternalLogEntry
logsTrackingIdsByDate []string
LogsByActionId map[string][]*InternalLogEntry
LogsByBindingId map[string][]*InternalLogEntry
logmutex sync.RWMutex
MapActionIdToBinding map[string]*ActionBinding
MapActionIdToBindingLock sync.RWMutex
MapActionBindings map[string]*ActionBinding
MapActionBindingsLock sync.RWMutex
Cfg *config.Config
@ -86,7 +86,6 @@ type ExecutionRequest struct {
// easily serializable.
type InternalLogEntry struct {
Binding *ActionBinding
BindingID string
DatetimeStarted time.Time
DatetimeFinished time.Time
Output string
@ -110,7 +109,6 @@ type InternalLogEntry struct {
*/
ActionTitle string
ActionIcon string
ActionId string
}
type executorStepFunc func(*ExecutionRequest) bool
@ -122,8 +120,8 @@ func DefaultExecutor(cfg *config.Config) *Executor {
e.Cfg = cfg
e.logs = make(map[string]*InternalLogEntry)
e.logsTrackingIdsByDate = make([]string, 0)
e.LogsByActionId = make(map[string][]*InternalLogEntry)
e.MapActionIdToBinding = make(map[string]*ActionBinding)
e.LogsByBindingId = make(map[string][]*InternalLogEntry)
e.MapActionBindings = make(map[string]*ActionBinding)
e.chainOfCommand = []executorStepFunc{
stepRequestAction,
@ -297,10 +295,10 @@ func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
return entry, found
}
func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry {
e.logmutex.RLock()
logs, found := e.LogsByActionId[actionId]
logs, found := e.LogsByBindingId[bindingId]
e.logmutex.RUnlock()
@ -311,6 +309,66 @@ func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
return logs
}
// 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
}
e.logmutex.RLock()
defer e.logmutex.RUnlock()
logs, found := e.LogsByBindingId[binding.ID]
if !found || len(logs) == 0 {
return 0
}
now := time.Now()
var maxExpiryTime time.Time
for _, rate := range binding.Action.MaxRate {
duration := parseDuration(rate)
if duration <= 0 {
continue
}
then := now.Add(-duration)
executions := 0
var oldestExecutionTime *time.Time
for _, logEntry := range logs {
if logEntry.Blocked {
continue
}
if logEntry.DatetimeStarted.After(then) {
executions++
if oldestExecutionTime == nil || logEntry.DatetimeStarted.Before(*oldestExecutionTime) {
oldestExecutionTime = &logEntry.DatetimeStarted
}
}
}
// If we're at or over the limit, calculate when the oldest execution will fall outside the window
// Note: getExecutionsCount uses -1 because it counts the current execution, but we're checking
// availability before execution, so we compare directly to rate.Limit
if executions >= rate.Limit && oldestExecutionTime != nil {
// The oldest execution will fall outside the window at: oldestExecutionTime + duration
expiryTime := oldestExecutionTime.Add(duration)
if expiryTime.After(now) && (maxExpiryTime.IsZero() || expiryTime.After(maxExpiryTime)) {
maxExpiryTime = expiryTime
}
}
}
if maxExpiryTime.IsZero() {
return 0
}
return maxExpiryTime.Unix()
}
func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) {
e.logmutex.Lock()
@ -337,7 +395,6 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
ExitCode: DefaultExitCodeNotExecuted,
ExecutionStarted: false,
ExecutionFinished: false,
ActionId: "",
ActionTitle: "notfound",
ActionIcon: "&#x1f4a9;",
Username: req.AuthenticatedUser.Username,
@ -374,6 +431,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
// This isn't a step, because we want to notify all listeners, irrespective
@ -386,7 +448,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
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 {
concurrentCount += 1
}
@ -436,7 +498,7 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
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
/*
if logEntry.EntityPrefix != req.EntityPrefix {
@ -573,16 +635,15 @@ func stepRequestAction(req *ExecutionRequest) bool {
req.logEntry.ActionConfigTitle = req.Binding.Action.Title
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
req.logEntry.ActionIcon = req.Binding.Action.Icon
req.logEntry.ActionId = req.Binding.Action.ID
req.logEntry.Tags = req.Tags
req.executor.logmutex.Lock()
if _, containsKey := req.executor.LogsByActionId[req.Binding.Action.ID]; !containsKey {
req.executor.LogsByActionId[req.Binding.Action.ID] = make([]*InternalLogEntry, 0)
if _, containsKey := req.executor.LogsByBindingId[req.Binding.ID]; !containsKey {
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()

View File

@ -11,9 +11,9 @@ import (
)
func (e *Executor) FindBindingByID(id string) *ActionBinding {
e.MapActionIdToBindingLock.RLock()
pair, found := e.MapActionIdToBinding[id]
e.MapActionIdToBindingLock.RUnlock()
e.MapActionBindingsLock.RLock()
pair, found := e.MapActionBindings[id]
e.MapActionBindingsLock.RUnlock()
if !found {
return nil
@ -23,11 +23,11 @@ func (e *Executor) FindBindingByID(id string) *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 {
return binding
}
@ -42,9 +42,9 @@ type RebuildActionMapRequest struct {
}
func (e *Executor) RebuildActionMap() {
e.MapActionIdToBindingLock.Lock()
e.MapActionBindingsLock.Lock()
clear(e.MapActionIdToBinding)
clear(e.MapActionBindings)
req := &RebuildActionMapRequest{
Cfg: e.Cfg,
@ -65,7 +65,7 @@ func (e *Executor) RebuildActionMap() {
}
}
e.MapActionIdToBindingLock.Unlock()
e.MapActionBindingsLock.Unlock()
for _, l := range e.listeners {
l.OnActionMapRebuilt()
@ -100,10 +100,10 @@ func recurseDashboardForActionTitles(component *config.DashboardComponent, req *
}
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
actionId := hashActionToID(action, "")
bindingId := generateActionBindingId(action, "")
e.MapActionIdToBinding[actionId] = &ActionBinding{
ID: actionId,
e.MapActionBindings[bindingId] = &ActionBinding{
ID: bindingId,
Action: action,
Entity: nil,
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) {
virtualActionId := hashActionToID(tpl, ent.UniqueKey)
virtualActionId := generateActionBindingId(tpl, ent.UniqueKey)
e.MapActionIdToBinding[virtualActionId] = &ActionBinding{
e.MapActionBindings[virtualActionId] = &ActionBinding{
ID: virtualActionId,
Action: tpl,
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 == "" {
return action.ID
}