Explorar o código

Merge branch 'next' into feat-log-calendar

James Read hai 5 meses
pai
achega
984e3daafb
Modificáronse 28 ficheiros con 1014 adicións e 244 borrados
  1. 1 1
      config.yaml
  2. 19 1
      frontend/js/websocket.js
  3. 4 5
      frontend/package-lock.json
  4. 2 2
      frontend/package.json
  5. 33 12
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  6. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  7. 121 7
      frontend/resources/vue/ActionButton.vue
  8. 14 3
      frontend/resources/vue/App.vue
  9. 1 1
      frontend/resources/vue/Dashboard.vue
  10. 1 1
      frontend/resources/vue/components/DashboardComponent.vue
  11. 1 1
      frontend/resources/vue/components/DashboardComponentDirectory.vue
  12. 1 1
      frontend/resources/vue/components/DashboardComponentDisplay.vue
  13. 45 31
      frontend/resources/vue/components/DashboardComponentMostRecentExecution.vue
  14. 5 0
      frontend/resources/vue/stores/rateLimits.js
  15. 62 2
      frontend/resources/vue/views/ArgumentForm.vue
  16. 2 0
      integration-tests/tests/stdoutMostRecentExecution/config.yaml
  17. 3 4
      integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs
  18. 19 0
      integration-tests/tests/suggestionsBrowserKey/config.yaml
  19. 325 0
      integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs
  20. 6 3
      proto/olivetin/api/v1/olivetin.proto
  21. 105 77
      service/gen/olivetin/api/v1/olivetin.pb.go
  22. 52 28
      service/internal/api/api.go
  23. 27 17
      service/internal/api/apiActions.go
  24. 6 6
      service/internal/api/api_test.go
  25. 1 1
      service/internal/api/dashboards.go
  26. 10 9
      service/internal/config/config.go
  27. 133 16
      service/internal/executor/executor.go
  28. 15 15
      service/internal/executor/executor_actions.go

+ 1 - 1
config.yaml

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

+ 19 - 1
frontend/js/websocket.js

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

+ 4 - 5
frontend/package-lock.json

@@ -17,11 +17,10 @@
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.12.5",
+				"picocrank": "^1.13.0",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^30.0.0",
 				"vite": "^7.3.1",
-				"vue": "^3.5.26",
 				"vue-i18n": "^11.2.8",
 				"vue-router": "^4.6.4"
 			},
@@ -4608,9 +4607,9 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.12.5",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.12.5.tgz",
-			"integrity": "sha512-z0EP/I56cFGzvXV4EAEpkczYDYkdHGtRfHQA+k7rbrBEHMO1fi7qW8VbDj7/2eqeG6IbNqWRIG1IexRQWZj7bQ==",
+			"version": "1.13.0",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.13.0.tgz",
+			"integrity": "sha512-kfUU2KVZnHyZ3/s+vM2tw25G+rK//eMjhF7cuXNQm0Ar/o5YvPiLBx8uxCrNNNvhGqb+jQK1Jbn41KO6rAR8Lw==",
 			"license": "ISC",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^3.1.1",

+ 2 - 2
frontend/package.json

@@ -30,11 +30,11 @@
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.12.5",
+		"picocrank": "^1.13.0",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^30.0.0",
 		"vite": "^7.3.1",
-		"vue": "^3.5.26",
+    "vue": "^3.5.26",
 		"vue-i18n": "^11.2.8",
 		"vue-router": "^4.6.4"
 	}

+ 33 - 12
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -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;
 };
 
 /**
@@ -99,6 +106,11 @@ export declare type ActionArgument = Message<"olivetin.api.v1.ActionArgument"> &
    * @generated from field: map<string, string> suggestions = 7;
    */
   suggestions: { [key: string]: string };
+
+  /**
+   * @generated from field: string suggestions_browser_key = 8;
+   */
+  suggestionsBrowserKey: string;
 };
 
 /**
@@ -543,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;
    */
@@ -572,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;
 };
 
 /**
@@ -904,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;
    */
@@ -919,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
@@ -946,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 };
 };
 
 /**

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 121 - 7
frontend/resources/vue/ActionButton.vue

@@ -1,7 +1,7 @@
 <template>
-	<div :id="`actionButton-${actionId}`" role="none" class="action-button">
-		<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
-													  :class="buttonClasses" @click="handleClick">
+	<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">
 				<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="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 } from 'vue'
+import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
 
 const router = useRouter()
 const navigateOnStart = ref('')
@@ -38,10 +39,15 @@ const props = defineProps({
   actionData: {
 	type: Object,
 	required: true
+  },
+  cssClass: {
+	type: String,
+	required: false,
+	default: ''
   }
 })
 
-const actionId = ref('')
+const bindingId = ref('')
 const title = ref('')
 const canExec = ref(true)
 const popupOnStart = ref('')
@@ -54,9 +60,24 @@ 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([])
 
+// Combined classes including custom cssClass
+const combinedClasses = computed(() => {
+	const classes = [...buttonClasses.value]
+	if (props.cssClass) {
+		classes.push(props.cssClass)
+	}
+	return classes
+})
+
 // Timestamps
 const updateIterationTimestamp = ref(0)
 
@@ -75,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
@@ -89,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) {
@@ -96,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() {
@@ -199,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(
@@ -256,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;

+ 14 - 3
frontend/resources/vue/App.vue

@@ -1,5 +1,5 @@
 <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>
             <div id="banner" v-if="bannerMessage" :style="bannerCss">
                 <p>{{ bannerMessage }}</p>
@@ -19,8 +19,8 @@
     </Header>
 
     <div id="layout">
-        <Navigation ref="navigation" v-if="showNavigation">
-            <Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
+        <Navigation ref="navigation">
+            <Sidebar ref="sidebar" id = "mainnav" v-if="sidebarEnabled && showNavigation"/>
         </Navigation>
 
 		<div id="content" initial-martial-complete="{{ hasLoaded }}">
@@ -108,6 +108,7 @@ const showNavigation = ref(true)
 const showLogs = ref(true)
 const showDiagnostics = ref(true)
 const showLoginLink = ref(true)
+const sectionNavigationStyle = ref('sidebar')
 
 const languageDialog = ref(null)
 const browserLanguages = ref([])
@@ -135,6 +136,15 @@ const currentLanguageName = computed(() => {
     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() {
     const available = Object.keys(combinedTranslations.messages || {})
 
@@ -180,6 +190,7 @@ function updateHeaderFromInit() {
     showNavigation.value = window.initResponse.showNavigation
     showLogs.value = window.initResponse.showLogList
     showDiagnostics.value = window.initResponse.showDiagnostics
+    sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
 
     if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
         showLoginLink.value = false

+ 1 - 1
frontend/resources/vue/Dashboard.vue

@@ -45,7 +45,7 @@
                     <span v-else>{{ component.title }}</span>
                 </h2>
 
-                <fieldset>
+                <fieldset :class="component.cssClass">
                     <template v-for="subcomponent in component.contents">
                         <DashboardComponent :component="subcomponent" />
                     </template>

+ 1 - 1
frontend/resources/vue/components/DashboardComponent.vue

@@ -1,5 +1,5 @@
 <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" />
 

+ 1 - 1
frontend/resources/vue/components/DashboardComponentDirectory.vue

@@ -1,5 +1,5 @@
 <template>
-    <button @click="navigateToDirectory">
+    <button @click="navigateToDirectory" :class="component.cssClass">
         <span class="icon" v-html="unicodeIcon"></span>
         <span class="title">{{ component.title }}</span>
     </button>

+ 1 - 1
frontend/resources/vue/components/DashboardComponentDisplay.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="display">
+    <div class="display" :class="component.cssClass">
         <div v-html="component.title" />
     </div>
 </template>

+ 45 - 31
frontend/resources/vue/components/DashboardComponentMostRecentExecution.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="mre-container">   
+  <div class="mre-container" :class="component.cssClass">   
     <router-link 
         v-if="executionTrackingId" 
         :to="`/logs/${executionTrackingId}`" 
@@ -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>

+ 5 - 0
frontend/resources/vue/stores/rateLimits.js

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

+ 62 - 2
frontend/resources/vue/views/ArgumentForm.vue

@@ -12,10 +12,13 @@
                 {{ formatLabel(arg.title) }}
               </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">
                   {{ suggestion }}
                 </option>
+                <option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
+                  {{ suggestion }}
+                </option>
               </datalist>
 
               <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" 
                 :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
                 :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"
                 :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
                 :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) {
   const startActionArgs = {
     bindingId: props.bindingId,
@@ -356,6 +413,9 @@ async function handleSubmit(event) {
   const argvs = getArgumentValues()
   console.log('argument form has elements that passed validation')
   
+  // Save values to localStorage for arguments with suggestionsBrowserKey
+  saveBrowserSuggestions()
+  
   try {
     const response = await startAction(argvs)
     router.push(`/logs/${response.executionTrackingId}`)

+ 2 - 0
integration-tests/tests/stdoutMostRecentExecution/config.yaml

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

+ 3 - 4
integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs

@@ -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
@@ -138,4 +137,4 @@ describe('config: stdout-most-recent-execution', function () {
     expect(updatedText).to.not.include('No execution found')
     expect(updatedText.trim().length).to.be.greaterThan(0)
   })
-})
+})

+ 19 - 0
integration-tests/tests/suggestionsBrowserKey/config.yaml

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

+ 325 - 0
integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs

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

+ 6 - 3
proto/olivetin/api/v1/olivetin.proto

@@ -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 {
@@ -25,6 +26,7 @@ message ActionArgument {
 
 	string description = 6;
 	map<string, string> suggestions = 7;
+	string suggestions_browser_key = 8;
 }
 
 message ActionArgumentChoice {
@@ -132,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 {
@@ -215,7 +218,7 @@ message DumpVarsResponse {
 	map<string, string> contents = 2;
 }
 
-message ActionEntityPair {
+message DebugBinding {
 	string action_title = 1;
 	string entity_prefix = 2;
 }
@@ -223,7 +226,7 @@ message ActionEntityPair {
 message DumpPublicIdActionMapRequest {}
 message DumpPublicIdActionMapResponse {
 	string alert = 1;
-	map<string, ActionEntityPair> contents = 2;
+	map<string, DebugBinding> contents = 2;
 }
 
 message GetReadyzRequest {}

+ 105 - 77
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -22,17 +22,18 @@ const (
 )
 
 type Action struct {
-	state         protoimpl.MessageState `protogen:"open.v1"`
-	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"`
-	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"`
-	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"`
-	Order         int32                  `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"`
-	Timeout       int32                  `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	state                    protoimpl.MessageState `protogen:"open.v1"`
+	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"`
+	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"`
+	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"`
+	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
 }
 
 func (x *Action) Reset() {
@@ -121,17 +122,25 @@ 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"`
-	Title         string                  `protobuf:"bytes,2,opt,name=title,proto3" json:"title,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"`
-	Choices       []*ActionArgumentChoice `protobuf:"bytes,5,rep,name=choices,proto3" json:"choices,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"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	state                 protoimpl.MessageState  `protogen:"open.v1"`
+	Name                  string                  `protobuf:"bytes,1,opt,name=name,proto3" json:"name,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"`
+	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"`
+	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"`
+	SuggestionsBrowserKey string                  `protobuf:"bytes,8,opt,name=suggestions_browser_key,json=suggestionsBrowserKey,proto3" json:"suggestions_browser_key,omitempty"`
+	unknownFields         protoimpl.UnknownFields
+	sizeCache             protoimpl.SizeCache
 }
 
 func (x *ActionArgument) Reset() {
@@ -213,6 +222,13 @@ func (x *ActionArgument) GetSuggestions() map[string]string {
 	return nil
 }
 
+func (x *ActionArgument) GetSuggestionsBrowserKey() string {
+	if x != nil {
+		return x.SuggestionsBrowserKey
+	}
+	return ""
+}
+
 type ActionArgumentChoice struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Value         string                 `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
@@ -1130,26 +1146,27 @@ func (x *GetLogsRequest) GetStartOffset() int64 {
 }
 
 type LogEntry struct {
-	state               protoimpl.MessageState `protogen:"open.v1"`
-	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"`
-	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"`
-	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"`
-	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"`
-	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"`
-	unknownFields       protoimpl.UnknownFields
-	sizeCache           protoimpl.SizeCache
+	state                    protoimpl.MessageState `protogen:"open.v1"`
+	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"`
+	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"`
+	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"`
+	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"`
+	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"`
+	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
 }
 
 func (x *LogEntry) Reset() {
@@ -1259,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
@@ -1301,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"`
@@ -2089,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"`
@@ -2097,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))
@@ -2122,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
 	}
@@ -2178,9 +2202,9 @@ 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"`
+	state         protoimpl.MessageState   `protogen:"open.v1"`
+	Alert         string                   `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"`
+	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
 }
@@ -2222,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
 	}
@@ -3839,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" +
@@ -3849,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\"\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" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@@ -3857,7 +3882,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\rdefault_value\x18\x04 \x01(\tR\fdefaultValue\x12?\n" +
 	"\achoices\x18\x05 \x03(\v2%.olivetin.api.v1.ActionArgumentChoiceR\achoices\x12 \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" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
 	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +
@@ -3924,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" +
@@ -3939,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" +
@@ -3997,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" +
@@ -4181,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
@@ -4250,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

+ 52 - 28
service/internal/api/api.go

@@ -16,6 +16,7 @@ import (
 	"fmt"
 	"net/http"
 	"sync"
+	"time"
 
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	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 {
 	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,
-		Output:              logEntry.Output,
-		TimedOut:            logEntry.TimedOut,
-		Blocked:             logEntry.Blocked,
-		ExitCode:            logEntry.ExitCode,
-		Tags:                logEntry.Tags,
-		ExecutionTrackingId: logEntry.ExecutionTrackingID,
-		ExecutionStarted:    logEntry.ExecutionStarted,
-		ExecutionFinished:   logEntry.ExecutionFinished,
-		User:                logEntry.Username,
+		ActionTitle:              logEntry.ActionTitle,
+		ActionIcon:               logEntry.ActionIcon,
+		DatetimeStarted:          logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
+		DatetimeFinished:         logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
+		DatetimeIndex:            logEntry.Index,
+		Output:                   logEntry.Output,
+		TimedOut:                 logEntry.TimedOut,
+		Blocked:                  logEntry.Blocked,
+		ExitCode:                 logEntry.ExitCode,
+		Tags:                     logEntry.Tags,
+		ExecutionTrackingId:      logEntry.ExecutionTrackingID,
+		ExecutionStarted:         logEntry.ExecutionStarted,
+		ExecutionFinished:        logEntry.ExecutionFinished,
+		User:                     logEntry.Username,
+		BindingId:                logEntry.Binding.ID,
+		DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
 	}
 
 	if !pble.ExecutionFinished {
@@ -311,10 +326,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 +366,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 +564,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 +713,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 +721,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{
-			ActionTitle:  v.Action.Title,
-			EntityPrefix: "?",
+	for k, v := range api.executor.MapActionBindings {
+		res.Contents[k] = &apiv1.DebugBinding{
+			ActionTitle: v.Action.Title,
 		}
 	}
 
-	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 +837,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 +845,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),
 				},
 			},
 		}:

+ 27 - 17
service/internal/api/apiActions.go

@@ -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,25 +111,34 @@ 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),
-		Icon:         entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
-		CanExec:      aclCanExec && enabledExprCanExec,
-		PopupOnStart: action.PopupOnStart,
-		Order:        int32(actionBinding.ConfigOrder),
-		Timeout:      int32(action.Timeout),
+		BindingId:                actionBinding.ID,
+		Title:                    entities.ParseTemplateWith(action.Title, actionBinding.Entity),
+		Icon:                     entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
+		CanExec:                  aclCanExec && enabledExprCanExec,
+		PopupOnStart:             action.PopupOnStart,
+		Order:                    int32(actionBinding.ConfigOrder),
+		Timeout:                  int32(action.Timeout),
+		DatetimeRateLimitExpires: datetimeRateLimitExpires,
 	}
 
 	for _, cfgArg := range action.Arguments {
 		pbArg := apiv1.ActionArgument{
-			Name:         cfgArg.Name,
-			Title:        cfgArg.Title,
-			Type:         cfgArg.Type,
-			Description:  cfgArg.Description,
-			DefaultValue: cfgArg.Default,
-			Choices:      buildChoices(cfgArg),
-			Suggestions:  cfgArg.Suggestions,
+			Name:                  cfgArg.Name,
+			Title:                 cfgArg.Title,
+			Type:                  cfgArg.Type,
+			Description:           cfgArg.Description,
+			DefaultValue:          cfgArg.Default,
+			Choices:               buildChoices(cfgArg),
+			Suggestions:           cfgArg.Suggestions,
+			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
 		}
 
 		btn.Arguments = append(btn.Arguments, &pbArg)

+ 6 - 6
service/internal/api/api_test.go

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

+ 1 - 1
service/internal/api/dashboards.go

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

+ 10 - 9
service/internal/config/config.go

@@ -34,15 +34,16 @@ type Action struct {
 
 // ActionArgument objects appear on Actions.
 type ActionArgument struct {
-	Name        string                 `koanf:"name"`
-	Title       string                 `koanf:"title"`
-	Description string                 `koanf:"description"`
-	Type        string                 `koanf:"type"`
-	Default     string                 `koanf:"default"`
-	Choices     []ActionArgumentChoice `koanf:"choices"`
-	Entity      string                 `koanf:"entity"`
-	RejectNull  bool                   `koanf:"rejectNull"`
-	Suggestions map[string]string      `koanf:"suggestions"`
+	Name                  string                 `koanf:"name"`
+	Title                 string                 `koanf:"title"`
+	Description           string                 `koanf:"description"`
+	Type                  string                 `koanf:"type"`
+	Default               string                 `koanf:"default"`
+	Choices               []ActionArgumentChoice `koanf:"choices"`
+	Entity                string                 `koanf:"entity"`
+	RejectNull            bool                   `koanf:"rejectNull"`
+	Suggestions           map[string]string      `koanf:"suggestions"`
+	SuggestionsBrowserKey string                 `koanf:"suggestionsBrowserKey"`
 }
 
 // ActionArgumentChoice represents a predefined choice for an argument.

+ 133 - 16
service/internal/executor/executor.go

@@ -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,122 @@ func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
 	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) {
 	e.logmutex.Lock()
 
@@ -337,7 +451,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 +487,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 +504,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 +554,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 +691,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()
 

+ 15 - 15
service/internal/executor/executor_actions.go

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

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio