ActionButton.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. <template>
  2. <div :id="`actionButton-${bindingId}`" role="none" class="action-button">
  3. <button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
  4. :class="combinedClasses" @click="handleClick">
  5. <div v-if="showNavigateOnStartIcons" class="navigate-on-start-container">
  6. <div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
  7. <HugeiconsIcon :icon="ComputerTerminal01Icon" />
  8. </div>
  9. <div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
  10. <HugeiconsIcon :icon="TypeCursorIcon" />
  11. </div>
  12. <div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
  13. <HugeiconsIcon :icon="WorkoutRunIcon" />
  14. </div>
  15. </div>
  16. <span class="icon" v-html="unicodeIcon"></span>
  17. <span class="title" aria-live="polite">{{ displayTitle }}
  18. </span>
  19. <span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
  20. </button>
  21. </div>
  22. </template>
  23. <script setup>
  24. import { buttonResults } from './stores/buttonResults'
  25. import { rateLimits } from './stores/rateLimits'
  26. import { useRouter } from 'vue-router'
  27. import { HugeiconsIcon } from '@hugeicons/vue'
  28. import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
  29. import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
  30. const router = useRouter()
  31. const navigateOnStart = ref('')
  32. const props = defineProps({
  33. actionData: {
  34. type: Object,
  35. required: true
  36. },
  37. cssClass: {
  38. type: String,
  39. required: false,
  40. default: ''
  41. }
  42. })
  43. const bindingId = ref('')
  44. const title = ref('')
  45. const canExec = ref(true)
  46. const popupOnStart = ref('')
  47. // Display properties
  48. const unicodeIcon = ref('&#x1f4a9;')
  49. const displayTitle = ref('')
  50. // State
  51. const isDisabled = ref(false)
  52. const showArgumentForm = ref(false)
  53. // Rate limiting
  54. const rateLimitExpires = ref(0)
  55. const isRateLimited = ref(false)
  56. const rateLimitMessage = ref('')
  57. let rateLimitInterval = null
  58. // Animation classes
  59. const buttonClasses = ref([])
  60. // Show navigate on start icons - defaults to true if not set
  61. const showNavigateOnStartIcons = computed(() => {
  62. return window.initResponse?.showNavigateOnStartIcons ?? true
  63. })
  64. // Combined classes including custom cssClass
  65. const combinedClasses = computed(() => {
  66. const classes = [...buttonClasses.value]
  67. if (props.cssClass) {
  68. classes.push(props.cssClass)
  69. }
  70. return classes
  71. })
  72. // Timestamps
  73. const updateIterationTimestamp = ref(0)
  74. function getUnicodeIcon(icon) {
  75. if (icon === '') {
  76. console.log('icon not found ', icon)
  77. return '&#x1f4a9;'
  78. } else {
  79. return unescape(icon)
  80. }
  81. }
  82. function constructFromJson(json) {
  83. updateIterationTimestamp.value = 0
  84. updateFromJson(json)
  85. bindingId.value = json.bindingId
  86. title.value = json.title
  87. canExec.value = json.canExec
  88. popupOnStart.value = json.popupOnStart
  89. if (popupOnStart.value.includes('execution-dialog')) {
  90. navigateOnStart.value = 'pop'
  91. } else if (props.actionData.arguments.length > 0) {
  92. navigateOnStart.value = 'arg'
  93. }
  94. isDisabled.value = !json.canExec
  95. displayTitle.value = title.value
  96. unicodeIcon.value = getUnicodeIcon(json.icon)
  97. // Initialize rate limit from action data (parse datetime string)
  98. if (json.datetimeRateLimitExpires) {
  99. const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
  100. rateLimitExpires.value = date.getTime() / 1000
  101. } else {
  102. rateLimitExpires.value = 0
  103. }
  104. // Also initialize the store so the watch picks it up
  105. if (bindingId.value) {
  106. rateLimits[bindingId.value] = rateLimitExpires.value
  107. }
  108. updateRateLimitStatus()
  109. }
  110. function updateFromJson(json) {
  111. // Fields that should not be updated
  112. // title - as the callback URL relies on it
  113. unicodeIcon.value = getUnicodeIcon(json.icon)
  114. // Update rate limiting if changed (parse datetime string)
  115. if (json.datetimeRateLimitExpires) {
  116. const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
  117. rateLimitExpires.value = date.getTime() / 1000
  118. updateRateLimitStatus()
  119. } else if (json.datetimeRateLimitExpires === '') {
  120. // Explicitly clear if empty string
  121. rateLimitExpires.value = 0
  122. updateRateLimitStatus()
  123. }
  124. }
  125. function updateRateLimitStatus() {
  126. if (rateLimitExpires.value === 0) {
  127. isRateLimited.value = false
  128. rateLimitMessage.value = ''
  129. if (rateLimitInterval) {
  130. clearInterval(rateLimitInterval)
  131. rateLimitInterval = null
  132. }
  133. return
  134. }
  135. const now = Math.floor(Date.now() / 1000)
  136. const expires = rateLimitExpires.value
  137. if (now >= expires) {
  138. // Rate limit has expired
  139. isRateLimited.value = false
  140. rateLimitMessage.value = ''
  141. rateLimitExpires.value = 0
  142. if (rateLimitInterval) {
  143. clearInterval(rateLimitInterval)
  144. rateLimitInterval = null
  145. }
  146. } else {
  147. // Still rate limited
  148. isRateLimited.value = true
  149. const secondsRemaining = expires - now
  150. rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
  151. // Set up interval to update every second
  152. if (!rateLimitInterval) {
  153. rateLimitInterval = setInterval(() => {
  154. updateRateLimitStatus()
  155. }, 1000)
  156. }
  157. }
  158. }
  159. async function handleClick() {
  160. if (props.actionData.arguments && props.actionData.arguments.length > 0) {
  161. router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
  162. } else {
  163. await startAction()
  164. }
  165. }
  166. function getUniqueId() {
  167. if (window.isSecureContext) {
  168. return window.crypto.randomUUID()
  169. } else {
  170. return Date.now().toString()
  171. }
  172. }
  173. async function startAction(actionArgs) {
  174. buttonClasses.value = [] // Removes old animation classes
  175. if (actionArgs === undefined) {
  176. actionArgs = []
  177. }
  178. // UUIDs are create client side, so that we can setup a "execution-button"
  179. // to track the execution before we send the request to the server.
  180. const startActionArgs = {
  181. bindingId: props.actionData.bindingId,
  182. arguments: actionArgs,
  183. uniqueTrackingId: getUniqueId()
  184. }
  185. console.log('Watching buttonResults for', startActionArgs.uniqueTrackingId)
  186. watch(
  187. () => buttonResults[startActionArgs.uniqueTrackingId],
  188. (newResult, oldResult) => {
  189. onLogEntryChanged(newResult)
  190. }
  191. )
  192. try {
  193. await window.client.startAction(startActionArgs)
  194. } catch (err) {
  195. console.error('Failed to start action:', err)
  196. }
  197. }
  198. function onLogEntryChanged(logEntry) {
  199. if (logEntry.executionFinished) {
  200. onExecutionFinished(logEntry)
  201. } else {
  202. onExecutionStarted(logEntry)
  203. }
  204. }
  205. function onExecutionStarted(logEntry) {
  206. if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
  207. router.push(`/logs/${logEntry.executionTrackingId}`)
  208. }
  209. isDisabled.value = true
  210. }
  211. function onExecutionFinished(logEntry) {
  212. if (logEntry.timedOut) {
  213. renderExecutionResult('action-timeout', 'Timed out')
  214. } else if (logEntry.blocked) {
  215. renderExecutionResult('action-blocked', 'Blocked!')
  216. } else if (logEntry.exitCode !== 0) {
  217. renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
  218. } else {
  219. const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
  220. renderExecutionResult('action-success', 'Success!')
  221. }
  222. }
  223. function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
  224. updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
  225. onExecStatusChanged()
  226. }
  227. function updateDom(resultCssClass, newTitle) {
  228. if (resultCssClass == null) {
  229. buttonClasses.value = []
  230. } else {
  231. buttonClasses.value = [resultCssClass]
  232. }
  233. displayTitle.value = newTitle
  234. }
  235. function onExecStatusChanged() {
  236. isDisabled.value = false
  237. setTimeout(() => {
  238. updateDom(null, title.value)
  239. }, 2000)
  240. }
  241. onMounted(() => {
  242. constructFromJson(props.actionData)
  243. // Watch the central rate limit store for updates to this button's bindingId
  244. // Watch the entire rateLimits object to ensure reactivity with dynamic keys
  245. watch(
  246. rateLimits,
  247. () => {
  248. const id = bindingId.value
  249. if (id && rateLimits[id] !== undefined) {
  250. const newExpires = rateLimits[id]
  251. if (newExpires !== rateLimitExpires.value) {
  252. rateLimitExpires.value = newExpires
  253. updateRateLimitStatus()
  254. }
  255. }
  256. },
  257. { deep: true }
  258. )
  259. })
  260. onUnmounted(() => {
  261. if (rateLimitInterval) {
  262. clearInterval(rateLimitInterval)
  263. rateLimitInterval = null
  264. }
  265. })
  266. watch(
  267. () => props.actionData,
  268. (newData) => {
  269. updateFromJson(newData)
  270. },
  271. { deep: true }
  272. )
  273. </script>
  274. <style>
  275. @layer components {
  276. .action-button {
  277. display: flex;
  278. flex-direction: column;
  279. flex-grow: 1;
  280. }
  281. .action-button button {
  282. display: flex;
  283. flex-direction: column;
  284. flex-grow: 1;
  285. justify-content: center;
  286. padding: 0.5em;
  287. border: 1px solid #ccc;
  288. border-radius: 4px;
  289. background: #fff;
  290. cursor: pointer;
  291. transition: all 0.2s ease;
  292. box-shadow: 0 0 .6em #aaa;
  293. font-size: .85em;
  294. border-radius: .7em;
  295. }
  296. .action-button button:hover:not(:disabled) {
  297. background: #f5f5f5;
  298. border-color: #999;
  299. }
  300. .action-button button:disabled {
  301. opacity: 0.6;
  302. cursor: not-allowed;
  303. }
  304. .action-button button .icon {
  305. font-size: 3em;
  306. flex-grow: 1;
  307. align-content: center;
  308. }
  309. .action-button button .title {
  310. font-weight: 500;
  311. padding: 0.2em;
  312. }
  313. .action-button button .rate-limit-message {
  314. font-size: 0.75em;
  315. color: #856404;
  316. padding: 0.2em;
  317. font-weight: normal;
  318. }
  319. /* Animation classes */
  320. .action-button button.action-timeout {
  321. background: #fff3cd;
  322. border-color: #ffeaa7;
  323. color: #856404;
  324. }
  325. .action-button button.action-blocked {
  326. background: #f8d7da !important;
  327. border-color: #f5c6cb;
  328. color: #721c24;
  329. }
  330. .action-button button.action-nonzero-exit {
  331. background: #f8d7da !important;
  332. border-color: #f5c6cb;
  333. color: #721c24;
  334. }
  335. .action-button button.action-success {
  336. background: #d4edda !important;
  337. border-color: #c3e6cb;
  338. color: #155724;
  339. }
  340. .action-button-footer {
  341. margin-top: 0.5em;
  342. }
  343. .navigate-on-start-container {
  344. position: relative;
  345. margin-left: auto;
  346. height: 0;
  347. right: 0;
  348. top: 0;
  349. }
  350. @media (prefers-color-scheme: dark) {
  351. .action-button button {
  352. background: #111;
  353. border-color: #000;
  354. box-shadow: 0 0 6px #000;
  355. color: #fff;
  356. }
  357. .action-button button:hover:not(:disabled) {
  358. background: #222;
  359. border-color: #000;
  360. box-shadow: 0 0 6px #444;
  361. color: #fff;
  362. }
  363. }
  364. }
  365. </style>