From 6493f7bee7073558aac1e282c329ba9079d17332 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 11 Jan 2026 01:10:20 +0000 Subject: [PATCH] feat: block notifications, and cleanup of action/binding IDs --- config.yaml | 2 +- frontend/js/websocket.js | 20 ++- .../gen/olivetin/api/v1/olivetin_pb.d.ts | 40 +++-- .../gen/olivetin/api/v1/olivetin_pb.js | 8 +- frontend/resources/vue/ActionButton.vue | 112 ++++++++++++- .../DashboardComponentMostRecentExecution.vue | 74 +++++---- frontend/resources/vue/stores/rateLimits.js | 5 + .../stdoutMostRecentExecution/config.yaml | 2 + .../stdoutMostRecentExecution.mjs | 7 +- proto/olivetin/api/v1/olivetin.proto | 8 +- service/gen/olivetin/api/v1/olivetin.pb.go | 151 ++++++++++-------- service/internal/api/api.go | 50 ++++-- service/internal/api/apiActions.go | 29 ++-- service/internal/api/api_test.go | 12 +- service/internal/api/dashboards.go | 2 +- service/internal/executor/executor.go | 93 +++++++++-- service/internal/executor/executor_actions.go | 30 ++-- 17 files changed, 456 insertions(+), 189 deletions(-) create mode 100644 frontend/resources/vue/stores/rateLimits.js diff --git a/config.yaml b/config.yaml index 5e3613a..38eec39 100644 --- a/config.yaml +++ b/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 diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index 9c4a8e2..6720b17 100644 --- a/frontend/js/websocket.js +++ b/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 + } +} \ No newline at end of file diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts index 090c581..c9db530 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts +++ b/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; }; /** @@ -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; /** - * @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; +export declare const DebugBindingSchema: GenMessage; /** * @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 contents = 2; + * @generated from field: map contents = 2; */ - contents: { [key: string]: ActionEntityPair }; + contents: { [key: string]: DebugBinding }; }; /** diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js index b6bd66d..0b9a6a0 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js +++ b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js @@ -8,7 +8,7 @@ import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2 * Describes the file olivetin/api/v1/olivetin.proto. */ export const file_olivetin_api_v1_olivetin = /*@__PURE__*/ - fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSK3AQoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBSK7AgoOQWN0aW9uQXJndW1lbnQSDAoEbmFtZRgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgR0eXBlGAMgASgJEhUKDWRlZmF1bHRfdmFsdWUYBCABKAkSNgoHY2hvaWNlcxgFIAMoCzIlLm9saXZldGluLmFwaS52MS5BY3Rpb25Bcmd1bWVudENob2ljZRITCgtkZXNjcmlwdGlvbhgGIAEoCRJFCgtzdWdnZXN0aW9ucxgHIAMoCzIwLm9saXZldGluLmFwaS52MS5BY3Rpb25Bcmd1bWVudC5TdWdnZXN0aW9uc0VudHJ5Eh8KF3N1Z2dlc3Rpb25zX2Jyb3dzZXJfa2V5GAggASgJGjIKEFN1Z2dlc3Rpb25zRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASI0ChRBY3Rpb25Bcmd1bWVudENob2ljZRINCgV2YWx1ZRgBIAEoCRINCgV0aXRsZRgCIAEoCSKyAQoGRW50aXR5Eg0KBXRpdGxlGAEgASgJEhIKCnVuaXF1ZV9rZXkYAiABKAkSDAoEdHlwZRgDIAEoCRITCgtkaXJlY3RvcmllcxgEIAMoCRIzCgZmaWVsZHMYBSADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRW50aXR5LkZpZWxkc0VudHJ5Gi0KC0ZpZWxkc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiVAoUR2V0RGFzaGJvYXJkUmVzcG9uc2USDQoFdGl0bGUYASABKAkSLQoJZGFzaGJvYXJkGAQgASgLMhoub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZCJCCg9FZmZlY3RpdmVQb2xpY3kSGAoQc2hvd19kaWFnbm9zdGljcxgBIAEoCBIVCg1zaG93X2xvZ19saXN0GAIgASgIIk0KE0dldERhc2hib2FyZFJlcXVlc3QSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCSJRCglEYXNoYm9hcmQSDQoFdGl0bGUYASABKAkSNQoIY29udGVudHMYAiADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50ItsBChJEYXNoYm9hcmRDb21wb25lbnQSDQoFdGl0bGUYASABKAkSDAoEdHlwZRgCIAEoCRI1Cghjb250ZW50cxgDIAMoCzIjLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmRDb21wb25lbnQSDAoEaWNvbhgEIAEoCRIRCgljc3NfY2xhc3MYBSABKAkSJwoGYWN0aW9uGAYgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbhITCgtlbnRpdHlfdHlwZRgHIAEoCRISCgplbnRpdHlfa2V5GAggASgJIn0KElN0YXJ0QWN0aW9uUmVxdWVzdBISCgpiaW5kaW5nX2lkGAEgASgJEjcKCWFyZ3VtZW50cxgCIAMoCzIkLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFyZ3VtZW50EhoKEnVuaXF1ZV90cmFja2luZ19pZBgDIAEoCSIyChNTdGFydEFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAkiNAoTU3RhcnRBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkiZwoZU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSNwoJYXJndW1lbnRzGAIgAygLMiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQXJndW1lbnQiSgoaU3RhcnRBY3Rpb25BbmRXYWl0UmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IiwKF1N0YXJ0QWN0aW9uQnlHZXRSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCSI5ChhTdGFydEFjdGlvbkJ5R2V0UmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAIgASgJIjMKHlN0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkiTwofU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiJgoOR2V0TG9nc1JlcXVlc3QSFAoMc3RhcnRfb2Zmc2V0GAEgASgDIvQCCghMb2dFbnRyeRIYChBkYXRldGltZV9zdGFydGVkGAEgASgJEhQKDGFjdGlvbl90aXRsZRgCIAEoCRIOCgZvdXRwdXQYAyABKAkSEQoJdGltZWRfb3V0GAUgASgIEhEKCWV4aXRfY29kZRgGIAEoBRIMCgR1c2VyGAcgASgJEhIKCnVzZXJfY2xhc3MYCCABKAkSEwoLYWN0aW9uX2ljb24YCSABKAkSDAoEdGFncxgKIAMoCRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYCyABKAkSGQoRZGF0ZXRpbWVfZmluaXNoZWQYDCABKAkSEQoJYWN0aW9uX2lkGA0gASgJEhkKEWV4ZWN1dGlvbl9zdGFydGVkGA4gASgIEhoKEmV4ZWN1dGlvbl9maW5pc2hlZBgPIAEoCBIPCgdibG9ja2VkGBAgASgIEhYKDmRhdGV0aW1lX2luZGV4GBEgASgDEhAKCGNhbl9raWxsGBIgASgIIpEBCg9HZXRMb2dzUmVzcG9uc2USJwoEbG9ncxgBIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeRIXCg9jb3VudF9yZW1haW5pbmcYAiABKAMSEQoJcGFnZV9zaXplGAMgASgDEhMKC3RvdGFsX2NvdW50GAQgASgDEhQKDHN0YXJ0X29mZnNldBgFIAEoAyI/ChRHZXRBY3Rpb25Mb2dzUmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSFAoMc3RhcnRfb2Zmc2V0GAIgASgDIpcBChVHZXRBY3Rpb25Mb2dzUmVzcG9uc2USJwoEbG9ncxgBIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeRIXCg9jb3VudF9yZW1haW5pbmcYAiABKAMSEQoJcGFnZV9zaXplGAMgASgDEhMKC3RvdGFsX2NvdW50GAQgASgDEhQKDHN0YXJ0X29mZnNldBgFIAEoAyJlChtWYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QSDQoFdmFsdWUYASABKAkSDAoEdHlwZRgCIAEoCRISCgpiaW5kaW5nX2lkGAMgASgJEhUKDWFyZ3VtZW50X25hbWUYBCABKAkiQgocVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZRINCgV2YWxpZBgBIAEoCBITCgtkZXNjcmlwdGlvbhgCIAEoCSI2ChVXYXRjaEV4ZWN1dGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIiYKFFdhdGNoRXhlY3V0aW9uVXBkYXRlEg4KBnVwZGF0ZRgBIAEoCSJKChZFeGVjdXRpb25TdGF0dXNSZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIRCglhY3Rpb25faWQYAiABKAkiRwoXRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5Ig8KDVdob0FtSVJlcXVlc3QibAoOV2hvQW1JUmVzcG9uc2USGgoSYXV0aGVudGljYXRlZF91c2VyGAEgASgJEhEKCXVzZXJncm91cBgCIAEoCRIQCghwcm92aWRlchgDIAEoCRIMCgRhY2xzGAQgAygJEgsKA3NpZBgFIAEoCSISChBTb3NSZXBvcnRSZXF1ZXN0IiIKEVNvc1JlcG9ydFJlc3BvbnNlEg0KBWFsZXJ0GAEgASgJIhEKD0R1bXBWYXJzUmVxdWVzdCKVAQoQRHVtcFZhcnNSZXNwb25zZRINCgVhbGVydBgBIAEoCRJBCghjb250ZW50cxgCIAMoCzIvLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1Jlc3BvbnNlLkNvbnRlbnRzRW50cnkaLwoNQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIj8KEEFjdGlvbkVudGl0eVBhaXISFAoMYWN0aW9uX3RpdGxlGAEgASgJEhUKDWVudGl0eV9wcmVmaXgYAiABKAkiHgocRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVxdWVzdCLSAQodRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2USDQoFYWxlcnQYASABKAkSTgoIY29udGVudHMYAiADKAsyPC5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2UuQ29udGVudHNFbnRyeRpSCg1Db250ZW50c0VudHJ5EgsKA2tleRgBIAEoCRIwCgV2YWx1ZRgCIAEoCzIhLm9saXZldGluLmFwaS52MS5BY3Rpb25FbnRpdHlQYWlyOgI4ASISChBHZXRSZWFkeXpSZXF1ZXN0IiMKEUdldFJlYWR5elJlc3BvbnNlEg4KBnN0YXR1cxgBIAEoCSIUChJFdmVudFN0cmVhbVJlcXVlc3Qi4wIKE0V2ZW50U3RyZWFtUmVzcG9uc2USPQoOZW50aXR5X2NoYW5nZWQYAiABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFbnRpdHlDaGFuZ2VkSAASPQoOY29uZmlnX2NoYW5nZWQYAyABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRDb25maWdDaGFuZ2VkSAASRQoSZXhlY3V0aW9uX2ZpbmlzaGVkGAQgASgLMicub2xpdmV0aW4uYXBpLnYxLkV2ZW50RXhlY3V0aW9uRmluaXNoZWRIABJDChFleGVjdXRpb25fc3RhcnRlZBgFIAEoCzImLm9saXZldGluLmFwaS52MS5FdmVudEV4ZWN1dGlvblN0YXJ0ZWRIABI5CgxvdXRwdXRfY2h1bmsYBiABKAsyIS5vbGl2ZXRpbi5hcGkudjEuRXZlbnRPdXRwdXRDaHVua0gAQgcKBWV2ZW50IkEKEEV2ZW50T3V0cHV0Q2h1bmsSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJEg4KBm91dHB1dBgCIAEoCSIUChJFdmVudEVudGl0eUNoYW5nZWQiFAoSRXZlbnRDb25maWdDaGFuZ2VkIkYKFkV2ZW50RXhlY3V0aW9uRmluaXNoZWQSLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IkUKFUV2ZW50RXhlY3V0aW9uU3RhcnRlZBIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiMgoRS2lsbEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIm0KEktpbGxBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSDgoGa2lsbGVkGAIgASgIEhkKEWFscmVhZHlfY29tcGxldGVkGAMgASgIEg0KBWZvdW5kGAQgASgIIjsKFUxvY2FsVXNlckxvZ2luUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSIpChZMb2NhbFVzZXJMb2dpblJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgiJwoTUGFzc3dvcmRIYXNoUmVxdWVzdBIQCghwYXNzd29yZBgBIAEoCSIkChRQYXNzd29yZEhhc2hSZXNwb25zZRIMCgRoYXNoGAEgASgJIg8KDUxvZ291dFJlcXVlc3QiEAoOTG9nb3V0UmVzcG9uc2UiFwoVR2V0RGlhZ25vc3RpY3NSZXF1ZXN0IkUKFkdldERpYWdub3N0aWNzUmVzcG9uc2USEwoLU3NoRm91bmRLZXkYASABKAkSFgoOU3NoRm91bmRDb25maWcYAiABKAkiDQoLSW5pdFJlcXVlc3QiqwUKDEluaXRSZXNwb25zZRISCgpzaG93Rm9vdGVyGAEgASgIEhYKDnNob3dOYXZpZ2F0aW9uGAIgASgIEhcKD3Nob3dOZXdWZXJzaW9ucxgDIAEoCBIYChBhdmFpbGFibGVWZXJzaW9uGAQgASgJEhYKDmN1cnJlbnRWZXJzaW9uGAUgASgJEhEKCXBhZ2VUaXRsZRgGIAEoCRIeChZzZWN0aW9uTmF2aWdhdGlvblN0eWxlGAcgASgJEhoKEmRlZmF1bHRJY29uRm9yQmFjaxgIIAEoCRIWCg5lbmFibGVDdXN0b21KcxgJIAEoCBIUCgxhdXRoTG9naW5VcmwYCiABKAkSFgoOYXV0aExvY2FsTG9naW4YCyABKAgSEQoJc3R5bGVNb2RzGAwgAygJEjgKD29BdXRoMlByb3ZpZGVycxgNIAMoCzIfLm9saXZldGluLmFwaS52MS5PQXV0aDJQcm92aWRlchI4Cg9hZGRpdGlvbmFsTGlua3MYDiADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWRkaXRpb25hbExpbmsSFgoOcm9vdERhc2hib2FyZHMYDyADKAkSGgoSYXV0aGVudGljYXRlZF91c2VyGBAgASgJEiMKG2F1dGhlbnRpY2F0ZWRfdXNlcl9wcm92aWRlchgRIAEoCRI6ChBlZmZlY3RpdmVfcG9saWN5GBIgASgLMiAub2xpdmV0aW4uYXBpLnYxLkVmZmVjdGl2ZVBvbGljeRIWCg5iYW5uZXJfbWVzc2FnZRgTIAEoCRISCgpiYW5uZXJfY3NzGBQgASgJEhgKEHNob3dfZGlhZ25vc3RpY3MYFSABKAgSFQoNc2hvd19sb2dfbGlzdBgWIAEoCBIWCg5sb2dpbl9yZXF1aXJlZBgXIAEoCCIsCg5BZGRpdGlvbmFsTGluaxINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkiOgoOT0F1dGgyUHJvdmlkZXISDQoFdGl0bGUYASABKAkSDAoEaWNvbhgDIAEoCRILCgNrZXkYBCABKAkiLQoXR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCSJDChhHZXRBY3Rpb25CaW5kaW5nUmVzcG9uc2USJwoGYWN0aW9uGAEgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbiIUChJHZXRFbnRpdGllc1JlcXVlc3QiVAoTR2V0RW50aXRpZXNSZXNwb25zZRI9ChJlbnRpdHlfZGVmaW5pdGlvbnMYASADKAsyIS5vbGl2ZXRpbi5hcGkudjEuRW50aXR5RGVmaW5pdGlvbiJpChBFbnRpdHlEZWZpbml0aW9uEg0KBXRpdGxlGAEgASgJEioKCWluc3RhbmNlcxgCIAMoCzIXLm9saXZldGluLmFwaS52MS5FbnRpdHkSGgoSdXNlZF9vbl9kYXNoYm9hcmRzGAMgAygJIjQKEEdldEVudGl0eVJlcXVlc3QSEgoKdW5pcXVlX2tleRgBIAEoCRIMCgR0eXBlGAIgASgJIjUKFFJlc3RhcnRBY3Rpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCTLoEgoST2xpdmVUaW5BcGlTZXJ2aWNlEl0KDEdldERhc2hib2FyZBIkLm9saXZldGluLmFwaS52MS5HZXREYXNoYm9hcmRSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlc3BvbnNlIgASWgoLU3RhcnRBY3Rpb24SIy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVzcG9uc2UiABJvChJTdGFydEFjdGlvbkFuZFdhaXQSKi5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBorLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXNwb25zZSIAEmkKEFN0YXJ0QWN0aW9uQnlHZXQSKC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldFJlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlIgASfgoXU3RhcnRBY3Rpb25CeUdldEFuZFdhaXQSLy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXF1ZXN0GjAub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2UiABJeCg1SZXN0YXJ0QWN0aW9uEiUub2xpdmV0aW4uYXBpLnYxLlJlc3RhcnRBY3Rpb25SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVzcG9uc2UiABJXCgpLaWxsQWN0aW9uEiIub2xpdmV0aW4uYXBpLnYxLktpbGxBY3Rpb25SZXF1ZXN0GiMub2xpdmV0aW4uYXBpLnYxLktpbGxBY3Rpb25SZXNwb25zZSIAEmYKD0V4ZWN1dGlvblN0YXR1cxInLm9saXZldGluLmFwaS52MS5FeGVjdXRpb25TdGF0dXNSZXF1ZXN0Gigub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1Jlc3BvbnNlIgASTgoHR2V0TG9ncxIfLm9saXZldGluLmFwaS52MS5HZXRMb2dzUmVxdWVzdBogLm9saXZldGluLmFwaS52MS5HZXRMb2dzUmVzcG9uc2UiABJgCg1HZXRBY3Rpb25Mb2dzEiUub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkxvZ3NSZXF1ZXN0GiYub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkxvZ3NSZXNwb25zZSIAEnUKFFZhbGlkYXRlQXJndW1lbnRUeXBlEiwub2xpdmV0aW4uYXBpLnYxLlZhbGlkYXRlQXJndW1lbnRUeXBlUmVxdWVzdBotLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlc3BvbnNlIgASSwoGV2hvQW1JEh4ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuV2hvQW1JUmVzcG9uc2UiABJUCglTb3NSZXBvcnQSIS5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVxdWVzdBoiLm9saXZldGluLmFwaS52MS5Tb3NSZXBvcnRSZXNwb25zZSIAElEKCER1bXBWYXJzEiAub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVxdWVzdBohLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1Jlc3BvbnNlIgASeAoVRHVtcFB1YmxpY0lkQWN0aW9uTWFwEi0ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlcXVlc3QaLi5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2UiABJUCglHZXRSZWFkeXoSIS5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVxdWVzdBoiLm9saXZldGluLmFwaS52MS5HZXRSZWFkeXpSZXNwb25zZSIAEmMKDkxvY2FsVXNlckxvZ2luEiYub2xpdmV0aW4uYXBpLnYxLkxvY2FsVXNlckxvZ2luUmVxdWVzdBonLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlc3BvbnNlIgASXQoMUGFzc3dvcmRIYXNoEiQub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuUGFzc3dvcmRIYXNoUmVzcG9uc2UiABJLCgZMb2dvdXQSHi5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVxdWVzdBofLm9saXZldGluLmFwaS52MS5Mb2dvdXRSZXNwb25zZSIAElwKC0V2ZW50U3RyZWFtEiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50U3RyZWFtUmVxdWVzdBokLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlc3BvbnNlIgAwARJjCg5HZXREaWFnbm9zdGljcxImLm9saXZldGluLmFwaS52MS5HZXREaWFnbm9zdGljc1JlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXNwb25zZSIAEkUKBEluaXQSHC5vbGl2ZXRpbi5hcGkudjEuSW5pdFJlcXVlc3QaHS5vbGl2ZXRpbi5hcGkudjEuSW5pdFJlc3BvbnNlIgASaQoQR2V0QWN0aW9uQmluZGluZxIoLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25CaW5kaW5nUmVxdWVzdBopLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25CaW5kaW5nUmVzcG9uc2UiABJaCgtHZXRFbnRpdGllcxIjLm9saXZldGluLmFwaS52MS5HZXRFbnRpdGllc1JlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXNwb25zZSIAEkkKCUdldEVudGl0eRIhLm9saXZldGluLmFwaS52MS5HZXRFbnRpdHlSZXF1ZXN0Ghcub2xpdmV0aW4uYXBpLnYxLkVudGl0eSIAQjhaNmdpdGh1Yi5jb20vT2xpdmVUaW4vT2xpdmVUaW4vZ2VuL29saXZldGluL2FwaS92MTthcGl2MWIGcHJvdG8z"); + fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSLcAQoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkiuwIKDkFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEdHlwZRgDIAEoCRIVCg1kZWZhdWx0X3ZhbHVlGAQgASgJEjYKB2Nob2ljZXMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnRDaG9pY2USEwoLZGVzY3JpcHRpb24YBiABKAkSRQoLc3VnZ2VzdGlvbnMYByADKAsyMC5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQuU3VnZ2VzdGlvbnNFbnRyeRIfChdzdWdnZXN0aW9uc19icm93c2VyX2tleRgIIAEoCRoyChBTdWdnZXN0aW9uc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiNAoUQWN0aW9uQXJndW1lbnRDaG9pY2USDQoFdmFsdWUYASABKAkSDQoFdGl0bGUYAiABKAkisgEKBkVudGl0eRINCgV0aXRsZRgBIAEoCRISCgp1bmlxdWVfa2V5GAIgASgJEgwKBHR5cGUYAyABKAkSEwoLZGlyZWN0b3JpZXMYBCADKAkSMwoGZmllbGRzGAUgAygLMiMub2xpdmV0aW4uYXBpLnYxLkVudGl0eS5GaWVsZHNFbnRyeRotCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIlQKFEdldERhc2hib2FyZFJlc3BvbnNlEg0KBXRpdGxlGAEgASgJEi0KCWRhc2hib2FyZBgEIAEoCzIaLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmQiQgoPRWZmZWN0aXZlUG9saWN5EhgKEHNob3dfZGlhZ25vc3RpY3MYASABKAgSFQoNc2hvd19sb2dfbGlzdBgCIAEoCCJNChNHZXREYXNoYm9hcmRSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEhMKC2VudGl0eV90eXBlGAIgASgJEhIKCmVudGl0eV9rZXkYAyABKAkiUQoJRGFzaGJvYXJkEg0KBXRpdGxlGAEgASgJEjUKCGNvbnRlbnRzGAIgAygLMiMub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZENvbXBvbmVudCLbAQoSRGFzaGJvYXJkQ29tcG9uZW50Eg0KBXRpdGxlGAEgASgJEgwKBHR5cGUYAiABKAkSNQoIY29udGVudHMYAyADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50EgwKBGljb24YBCABKAkSEQoJY3NzX2NsYXNzGAUgASgJEicKBmFjdGlvbhgGIAEoCzIXLm9saXZldGluLmFwaS52MS5BY3Rpb24SEwoLZW50aXR5X3R5cGUYByABKAkSEgoKZW50aXR5X2tleRgIIAEoCSJ9ChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkiMgoTU3RhcnRBY3Rpb25Bcmd1bWVudBIMCgRuYW1lGAEgASgJEg0KBXZhbHVlGAIgASgJIjQKE1N0YXJ0QWN0aW9uUmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAIgASgJImcKGVN0YXJ0QWN0aW9uQW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJEjcKCWFyZ3VtZW50cxgCIAMoCzIkLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFyZ3VtZW50IkoKGlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIsChdTdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkiOQoYU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgCIAEoCSIzCh5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIk8KH1N0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IiYKDkdldExvZ3NSZXF1ZXN0EhQKDHN0YXJ0X29mZnNldBgBIAEoAyKaAwoITG9nRW50cnkSGAoQZGF0ZXRpbWVfc3RhcnRlZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSDgoGb3V0cHV0GAMgASgJEhEKCXRpbWVkX291dBgFIAEoCBIRCglleGl0X2NvZGUYBiABKAUSDAoEdXNlchgHIAEoCRISCgp1c2VyX2NsYXNzGAggASgJEhMKC2FjdGlvbl9pY29uGAkgASgJEgwKBHRhZ3MYCiADKAkSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAsgASgJEhkKEWRhdGV0aW1lX2ZpbmlzaGVkGAwgASgJEhkKEWV4ZWN1dGlvbl9zdGFydGVkGA4gASgIEhoKEmV4ZWN1dGlvbl9maW5pc2hlZBgPIAEoCBIPCgdibG9ja2VkGBAgASgIEhYKDmRhdGV0aW1lX2luZGV4GBEgASgDEhAKCGNhbl9raWxsGBIgASgIEiMKG2RhdGV0aW1lX3JhdGVfbGltaXRfZXhwaXJlcxgTIAEoCRISCgpiaW5kaW5nX2lkGBQgASgJIpEBCg9HZXRMb2dzUmVzcG9uc2USJwoEbG9ncxgBIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeRIXCg9jb3VudF9yZW1haW5pbmcYAiABKAMSEQoJcGFnZV9zaXplGAMgASgDEhMKC3RvdGFsX2NvdW50GAQgASgDEhQKDHN0YXJ0X29mZnNldBgFIAEoAyI/ChRHZXRBY3Rpb25Mb2dzUmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSFAoMc3RhcnRfb2Zmc2V0GAIgASgDIpcBChVHZXRBY3Rpb25Mb2dzUmVzcG9uc2USJwoEbG9ncxgBIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeRIXCg9jb3VudF9yZW1haW5pbmcYAiABKAMSEQoJcGFnZV9zaXplGAMgASgDEhMKC3RvdGFsX2NvdW50GAQgASgDEhQKDHN0YXJ0X29mZnNldBgFIAEoAyJlChtWYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QSDQoFdmFsdWUYASABKAkSDAoEdHlwZRgCIAEoCRISCgpiaW5kaW5nX2lkGAMgASgJEhUKDWFyZ3VtZW50X25hbWUYBCABKAkiQgocVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZRINCgV2YWxpZBgBIAEoCBITCgtkZXNjcmlwdGlvbhgCIAEoCSI2ChVXYXRjaEV4ZWN1dGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIiYKFFdhdGNoRXhlY3V0aW9uVXBkYXRlEg4KBnVwZGF0ZRgBIAEoCSJKChZFeGVjdXRpb25TdGF0dXNSZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIRCglhY3Rpb25faWQYAiABKAkiRwoXRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5Ig8KDVdob0FtSVJlcXVlc3QibAoOV2hvQW1JUmVzcG9uc2USGgoSYXV0aGVudGljYXRlZF91c2VyGAEgASgJEhEKCXVzZXJncm91cBgCIAEoCRIQCghwcm92aWRlchgDIAEoCRIMCgRhY2xzGAQgAygJEgsKA3NpZBgFIAEoCSISChBTb3NSZXBvcnRSZXF1ZXN0IiIKEVNvc1JlcG9ydFJlc3BvbnNlEg0KBWFsZXJ0GAEgASgJIhEKD0R1bXBWYXJzUmVxdWVzdCKVAQoQRHVtcFZhcnNSZXNwb25zZRINCgVhbGVydBgBIAEoCRJBCghjb250ZW50cxgCIAMoCzIvLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1Jlc3BvbnNlLkNvbnRlbnRzRW50cnkaLwoNQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIjsKDERlYnVnQmluZGluZxIUCgxhY3Rpb25fdGl0bGUYASABKAkSFQoNZW50aXR5X3ByZWZpeBgCIAEoCSIeChxEdW1wUHVibGljSWRBY3Rpb25NYXBSZXF1ZXN0Is4BCh1EdW1wUHVibGljSWRBY3Rpb25NYXBSZXNwb25zZRINCgVhbGVydBgBIAEoCRJOCghjb250ZW50cxgCIAMoCzI8Lm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXNwb25zZS5Db250ZW50c0VudHJ5Gk4KDUNvbnRlbnRzRW50cnkSCwoDa2V5GAEgASgJEiwKBXZhbHVlGAIgASgLMh0ub2xpdmV0aW4uYXBpLnYxLkRlYnVnQmluZGluZzoCOAEiEgoQR2V0UmVhZHl6UmVxdWVzdCIjChFHZXRSZWFkeXpSZXNwb25zZRIOCgZzdGF0dXMYASABKAkiFAoSRXZlbnRTdHJlYW1SZXF1ZXN0IuMCChNFdmVudFN0cmVhbVJlc3BvbnNlEj0KDmVudGl0eV9jaGFuZ2VkGAIgASgLMiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50RW50aXR5Q2hhbmdlZEgAEj0KDmNvbmZpZ19jaGFuZ2VkGAMgASgLMiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50Q29uZmlnQ2hhbmdlZEgAEkUKEmV4ZWN1dGlvbl9maW5pc2hlZBgEIAEoCzInLm9saXZldGluLmFwaS52MS5FdmVudEV4ZWN1dGlvbkZpbmlzaGVkSAASQwoRZXhlY3V0aW9uX3N0YXJ0ZWQYBSABKAsyJi5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFeGVjdXRpb25TdGFydGVkSAASOQoMb3V0cHV0X2NodW5rGAYgASgLMiEub2xpdmV0aW4uYXBpLnYxLkV2ZW50T3V0cHV0Q2h1bmtIAEIHCgVldmVudCJBChBFdmVudE91dHB1dENodW5rEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIOCgZvdXRwdXQYAiABKAkiFAoSRXZlbnRFbnRpdHlDaGFuZ2VkIhQKEkV2ZW50Q29uZmlnQ2hhbmdlZCJGChZFdmVudEV4ZWN1dGlvbkZpbmlzaGVkEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSJFChVFdmVudEV4ZWN1dGlvblN0YXJ0ZWQSLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IjIKEUtpbGxBY3Rpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSJtChJLaWxsQWN0aW9uUmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJEg4KBmtpbGxlZBgCIAEoCBIZChFhbHJlYWR5X2NvbXBsZXRlZBgDIAEoCBINCgVmb3VuZBgEIAEoCCI7ChVMb2NhbFVzZXJMb2dpblJlcXVlc3QSEAoIdXNlcm5hbWUYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiKQoWTG9jYWxVc2VyTG9naW5SZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIIicKE1Bhc3N3b3JkSGFzaFJlcXVlc3QSEAoIcGFzc3dvcmQYASABKAkiJAoUUGFzc3dvcmRIYXNoUmVzcG9uc2USDAoEaGFzaBgBIAEoCSIPCg1Mb2dvdXRSZXF1ZXN0IhAKDkxvZ291dFJlc3BvbnNlIhcKFUdldERpYWdub3N0aWNzUmVxdWVzdCJFChZHZXREaWFnbm9zdGljc1Jlc3BvbnNlEhMKC1NzaEZvdW5kS2V5GAEgASgJEhYKDlNzaEZvdW5kQ29uZmlnGAIgASgJIg0KC0luaXRSZXF1ZXN0IqsFCgxJbml0UmVzcG9uc2USEgoKc2hvd0Zvb3RlchgBIAEoCBIWCg5zaG93TmF2aWdhdGlvbhgCIAEoCBIXCg9zaG93TmV3VmVyc2lvbnMYAyABKAgSGAoQYXZhaWxhYmxlVmVyc2lvbhgEIAEoCRIWCg5jdXJyZW50VmVyc2lvbhgFIAEoCRIRCglwYWdlVGl0bGUYBiABKAkSHgoWc2VjdGlvbk5hdmlnYXRpb25TdHlsZRgHIAEoCRIaChJkZWZhdWx0SWNvbkZvckJhY2sYCCABKAkSFgoOZW5hYmxlQ3VzdG9tSnMYCSABKAgSFAoMYXV0aExvZ2luVXJsGAogASgJEhYKDmF1dGhMb2NhbExvZ2luGAsgASgIEhEKCXN0eWxlTW9kcxgMIAMoCRI4Cg9vQXV0aDJQcm92aWRlcnMYDSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuT0F1dGgyUHJvdmlkZXISOAoPYWRkaXRpb25hbExpbmtzGA4gAygLMh8ub2xpdmV0aW4uYXBpLnYxLkFkZGl0aW9uYWxMaW5rEhYKDnJvb3REYXNoYm9hcmRzGA8gAygJEhoKEmF1dGhlbnRpY2F0ZWRfdXNlchgQIAEoCRIjChthdXRoZW50aWNhdGVkX3VzZXJfcHJvdmlkZXIYESABKAkSOgoQZWZmZWN0aXZlX3BvbGljeRgSIAEoCzIgLm9saXZldGluLmFwaS52MS5FZmZlY3RpdmVQb2xpY3kSFgoOYmFubmVyX21lc3NhZ2UYEyABKAkSEgoKYmFubmVyX2NzcxgUIAEoCRIYChBzaG93X2RpYWdub3N0aWNzGBUgASgIEhUKDXNob3dfbG9nX2xpc3QYFiABKAgSFgoObG9naW5fcmVxdWlyZWQYFyABKAgiLAoOQWRkaXRpb25hbExpbmsSDQoFdGl0bGUYASABKAkSCwoDdXJsGAIgASgJIjoKDk9BdXRoMlByb3ZpZGVyEg0KBXRpdGxlGAEgASgJEgwKBGljb24YAyABKAkSCwoDa2V5GAQgASgJIi0KF0dldEFjdGlvbkJpbmRpbmdSZXF1ZXN0EhIKCmJpbmRpbmdfaWQYASABKAkiQwoYR2V0QWN0aW9uQmluZGluZ1Jlc3BvbnNlEicKBmFjdGlvbhgBIAEoCzIXLm9saXZldGluLmFwaS52MS5BY3Rpb24iFAoSR2V0RW50aXRpZXNSZXF1ZXN0IlQKE0dldEVudGl0aWVzUmVzcG9uc2USPQoSZW50aXR5X2RlZmluaXRpb25zGAEgAygLMiEub2xpdmV0aW4uYXBpLnYxLkVudGl0eURlZmluaXRpb24iaQoQRW50aXR5RGVmaW5pdGlvbhINCgV0aXRsZRgBIAEoCRIqCglpbnN0YW5jZXMYAiADKAsyFy5vbGl2ZXRpbi5hcGkudjEuRW50aXR5EhoKEnVzZWRfb25fZGFzaGJvYXJkcxgDIAMoCSI0ChBHZXRFbnRpdHlSZXF1ZXN0EhIKCnVuaXF1ZV9rZXkYASABKAkSDAoEdHlwZRgCIAEoCSI1ChRSZXN0YXJ0QWN0aW9uUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAky6BIKEk9saXZlVGluQXBpU2VydmljZRJdCgxHZXREYXNoYm9hcmQSJC5vbGl2ZXRpbi5hcGkudjEuR2V0RGFzaGJvYXJkUmVxdWVzdBolLm9saXZldGluLmFwaS52MS5HZXREYXNoYm9hcmRSZXNwb25zZSIAEloKC1N0YXJ0QWN0aW9uEiMub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVxdWVzdBokLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvblJlc3BvbnNlIgASbwoSU3RhcnRBY3Rpb25BbmRXYWl0Eioub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQW5kV2FpdFJlcXVlc3QaKy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25BbmRXYWl0UmVzcG9uc2UiABJpChBTdGFydEFjdGlvbkJ5R2V0Eigub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQnlHZXRSZXF1ZXN0Gikub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQnlHZXRSZXNwb25zZSIAEn4KF1N0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0Ei8ub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVxdWVzdBowLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlc3BvbnNlIgASXgoNUmVzdGFydEFjdGlvbhIlLm9saXZldGluLmFwaS52MS5SZXN0YXJ0QWN0aW9uUmVxdWVzdBokLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvblJlc3BvbnNlIgASVwoKS2lsbEFjdGlvbhIiLm9saXZldGluLmFwaS52MS5LaWxsQWN0aW9uUmVxdWVzdBojLm9saXZldGluLmFwaS52MS5LaWxsQWN0aW9uUmVzcG9uc2UiABJmCg9FeGVjdXRpb25TdGF0dXMSJy5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uU3RhdHVzUmVxdWVzdBooLm9saXZldGluLmFwaS52MS5FeGVjdXRpb25TdGF0dXNSZXNwb25zZSIAEk4KB0dldExvZ3MSHy5vbGl2ZXRpbi5hcGkudjEuR2V0TG9nc1JlcXVlc3QaIC5vbGl2ZXRpbi5hcGkudjEuR2V0TG9nc1Jlc3BvbnNlIgASYAoNR2V0QWN0aW9uTG9ncxIlLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25Mb2dzUmVxdWVzdBomLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25Mb2dzUmVzcG9uc2UiABJ1ChRWYWxpZGF0ZUFyZ3VtZW50VHlwZRIsLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QaLS5vbGl2ZXRpbi5hcGkudjEuVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZSIAEksKBldob0FtSRIeLm9saXZldGluLmFwaS52MS5XaG9BbUlSZXF1ZXN0Gh8ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlc3BvbnNlIgASVAoJU29zUmVwb3J0EiEub2xpdmV0aW4uYXBpLnYxLlNvc1JlcG9ydFJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVzcG9uc2UiABJRCghEdW1wVmFycxIgLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1JlcXVlc3QaIS5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXNwb25zZSIAEngKFUR1bXBQdWJsaWNJZEFjdGlvbk1hcBItLm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXF1ZXN0Gi4ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlIgASVAoJR2V0UmVhZHl6EiEub2xpdmV0aW4uYXBpLnYxLkdldFJlYWR5elJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVzcG9uc2UiABJjCg5Mb2NhbFVzZXJMb2dpbhImLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuTG9jYWxVc2VyTG9naW5SZXNwb25zZSIAEl0KDFBhc3N3b3JkSGFzaBIkLm9saXZldGluLmFwaS52MS5QYXNzd29yZEhhc2hSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlc3BvbnNlIgASSwoGTG9nb3V0Eh4ub2xpdmV0aW4uYXBpLnYxLkxvZ291dFJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVzcG9uc2UiABJcCgtFdmVudFN0cmVhbRIjLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuRXZlbnRTdHJlYW1SZXNwb25zZSIAMAESYwoOR2V0RGlhZ25vc3RpY3MSJi5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXF1ZXN0Gicub2xpdmV0aW4uYXBpLnYxLkdldERpYWdub3N0aWNzUmVzcG9uc2UiABJFCgRJbml0Ehwub2xpdmV0aW4uYXBpLnYxLkluaXRSZXF1ZXN0Gh0ub2xpdmV0aW4uYXBpLnYxLkluaXRSZXNwb25zZSIAEmkKEEdldEFjdGlvbkJpbmRpbmcSKC5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1Jlc3BvbnNlIgASWgoLR2V0RW50aXRpZXMSIy5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLkdldEVudGl0aWVzUmVzcG9uc2UiABJJCglHZXRFbnRpdHkSIS5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXR5UmVxdWVzdBoXLm9saXZldGluLmFwaS52MS5FbnRpdHkiAEI4WjZnaXRodWIuY29tL09saXZlVGluL09saXZlVGluL2dlbi9vbGl2ZXRpbi9hcGkvdjE7YXBpdjFiBnByb3RvMw"); /** * Describes the message olivetin.api.v1.Action. @@ -256,10 +256,10 @@ export const DumpVarsResponseSchema = /*@__PURE__*/ messageDesc(file_olivetin_api_v1_olivetin, 34); /** - * 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 const ActionEntityPairSchema = /*@__PURE__*/ +export const DebugBindingSchema = /*@__PURE__*/ messageDesc(file_olivetin_api_v1_olivetin, 35); /** diff --git a/frontend/resources/vue/ActionButton.vue b/frontend/resources/vue/ActionButton.vue index 483d4b5..42b86dd 100644 --- a/frontend/resources/vue/ActionButton.vue +++ b/frontend/resources/vue/ActionButton.vue @@ -1,6 +1,6 @@ diff --git a/frontend/resources/vue/stores/rateLimits.js b/frontend/resources/vue/stores/rateLimits.js new file mode 100644 index 0000000..7676f88 --- /dev/null +++ b/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({}) diff --git a/integration-tests/tests/stdoutMostRecentExecution/config.yaml b/integration-tests/tests/stdoutMostRecentExecution/config.yaml index 9e448b2..dd1d511 100644 --- a/integration-tests/tests/stdoutMostRecentExecution/config.yaml +++ b/integration-tests/tests/stdoutMostRecentExecution/config.yaml @@ -1,5 +1,7 @@ logLevel: debug +insecureAllowDumpActionMap: true + actions: - title: Check status id: status_command diff --git a/integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs b/integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs index 6c72d96..5388eb0 100644 --- a/integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs +++ b/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) }) -}) \ No newline at end of file +}) diff --git a/proto/olivetin/api/v1/olivetin.proto b/proto/olivetin/api/v1/olivetin.proto index d0c312b..c93129b 100644 --- a/proto/olivetin/api/v1/olivetin.proto +++ b/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 { @@ -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 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 contents = 2; + map contents = 2; } message GetReadyzRequest {} diff --git a/service/gen/olivetin/api/v1/olivetin.pb.go b/service/gen/olivetin/api/v1/olivetin.pb.go index 427dbda..4cf87be 100644 --- a/service/gen/olivetin/api/v1/olivetin.pb.go +++ b/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,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"` @@ -1138,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() { @@ -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 } @@ -2186,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 } @@ -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 diff --git a/service/internal/api/api.go b/service/internal/api/api.go index 1d44fd8..25f48cb 100644 --- a/service/internal/api/api.go +++ b/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" @@ -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{ - 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 +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), }, }, }: diff --git a/service/internal/api/apiActions.go b/service/internal/api/apiActions.go index 94200f4..55728b9 100644 --- a/service/internal/api/apiActions.go +++ b/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,14 +111,22 @@ 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 { diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index 9d20559..c2977f7 100644 --- a/service/internal/api/api_test.go +++ b/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 } diff --git a/service/internal/api/dashboards.go b/service/internal/api/dashboards.go index eebf055..12ae2d5 100644 --- a/service/internal/api/dashboards.go +++ b/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 } diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index a42e8c7..62b4067 100644 --- a/service/internal/executor/executor.go +++ b/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,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: "💩", 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() diff --git a/service/internal/executor/executor_actions.go b/service/internal/executor/executor_actions.go index 3f41fc5..fdf504c 100644 --- a/service/internal/executor/executor_actions.go +++ b/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 }