ActionButton.vue 11 KB

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