ActionButton.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. <template>
  2. <div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
  3. <span
  4. v-if="showExecutionIndicator"
  5. class="execution-indicator"
  6. :class="executionIndicatorClass"
  7. :title="executionIndicatorTitle"
  8. aria-hidden="true"
  9. ></span>
  10. <button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
  11. :class="combinedClasses" @click="handleClick">
  12. <div v-if="showNavigateOnStartIcons" class="navigate-on-start-container">
  13. <div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
  14. <HugeiconsIcon :icon="ComputerTerminal01Icon" />
  15. </div>
  16. <div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
  17. <HugeiconsIcon :icon="TypeCursorIcon" />
  18. </div>
  19. <div v-if="navigateOnStart == 'hist'" class="navigate-on-start" title="Opens action execution history on start">
  20. <HugeiconsIcon :icon="WorkHistoryIcon" />
  21. </div>
  22. <div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
  23. <HugeiconsIcon :icon="WorkoutRunIcon" />
  24. </div>
  25. </div>
  26. <ActionIconGlyph class="icon" :glyph="actionGlyph" />
  27. <span class="title" aria-live="polite">{{ displayTitle }}
  28. </span>
  29. <span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
  30. </button>
  31. </div>
  32. </template>
  33. <script setup>
  34. import { buttonResults } from './stores/buttonResults'
  35. import { rateLimits } from './stores/rateLimits'
  36. import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
  37. import { connectionState } from './stores/connectionState'
  38. import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
  39. import { useRouter } from 'vue-router'
  40. import { needsArgumentForm } from './utils/needsArgumentForm.js'
  41. import { shouldSuppressPopupOnStartNavigation } from './utils/popupOnStartNavigation.js'
  42. import { HugeiconsIcon } from '@hugeicons/vue'
  43. import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
  44. import ActionIconGlyph from './components/ActionIconGlyph.vue'
  45. import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
  46. const router = useRouter()
  47. const navigateOnStart = ref('')
  48. const props = defineProps({
  49. actionData: {
  50. type: Object,
  51. required: true
  52. },
  53. cssClass: {
  54. type: String,
  55. required: false,
  56. default: ''
  57. }
  58. })
  59. const bindingId = ref('')
  60. const title = ref('')
  61. const canExec = ref(true)
  62. const popupOnStart = ref('')
  63. // Display properties
  64. const displayTitle = ref('')
  65. // State
  66. const isDisabled = ref(false)
  67. const showArgumentForm = ref(false)
  68. // Rate limiting
  69. const rateLimitExpires = ref(0)
  70. const isRateLimited = ref(false)
  71. const rateLimitMessage = ref('')
  72. const rateLimitInterval = ref(null)
  73. const isComponentMounted = ref(true)
  74. // Animation classes
  75. const buttonClasses = ref([])
  76. // Show navigate on start icons - defaults to true if not set
  77. const showNavigateOnStartIcons = computed(() => {
  78. return window.initResponse?.showNavigateOnStartIcons ?? true
  79. })
  80. const actionGlyph = computed(() => props.actionData?.icon ?? '')
  81. const glyph = ref('')
  82. // Combined classes including custom cssClass
  83. const combinedClasses = computed(() => {
  84. const classes = [...buttonClasses.value]
  85. if (props.cssClass) {
  86. classes.push(props.cssClass)
  87. }
  88. return classes
  89. })
  90. const hasRunningInstance = computed(() => {
  91. const id = bindingId.value
  92. return !!(id && bindingExecutionState[id]?.hasRunning)
  93. })
  94. const hasQueuedInstance = computed(() => {
  95. const id = bindingId.value
  96. return !!(id && bindingExecutionState[id]?.hasQueued)
  97. })
  98. const showExecutionIndicator = computed(() => {
  99. return hasRunningInstance.value || hasQueuedInstance.value
  100. })
  101. const executionIndicatorClass = computed(() => {
  102. if (hasRunningInstance.value) {
  103. return 'execution-indicator-running'
  104. }
  105. if (hasQueuedInstance.value) {
  106. return 'execution-indicator-queued'
  107. }
  108. return ''
  109. })
  110. const executionIndicatorTitle = computed(() => {
  111. if (hasRunningInstance.value) {
  112. return 'Running'
  113. }
  114. if (hasQueuedInstance.value) {
  115. return 'Queued'
  116. }
  117. return ''
  118. })
  119. // Timestamps
  120. const updateIterationTimestamp = ref(0)
  121. function constructFromJson(json) {
  122. updateIterationTimestamp.value = 0
  123. updateFromJson(json)
  124. bindingId.value = json.bindingId
  125. title.value = json.title
  126. canExec.value = json.canExec
  127. popupOnStart.value = json.popupOnStart
  128. if (popupOnStart.value.includes('execution-dialog')) {
  129. navigateOnStart.value = 'pop'
  130. } else if (popupOnStart.value === 'history') {
  131. navigateOnStart.value = 'hist'
  132. } else if (needsArgumentForm(props.actionData)) {
  133. navigateOnStart.value = 'arg'
  134. }
  135. isDisabled.value = !json.canExec
  136. displayTitle.value = title.value
  137. glyph.value = json.icon ?? ''
  138. // Initialize rate limit from action data (parse datetime string)
  139. if (json.datetimeRateLimitExpires) {
  140. const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
  141. rateLimitExpires.value = date.getTime() / 1000
  142. } else {
  143. rateLimitExpires.value = 0
  144. }
  145. // Also initialize the store so the watch picks it up
  146. if (bindingId.value) {
  147. rateLimits[bindingId.value] = rateLimitExpires.value
  148. setBindingExecutionState(
  149. bindingId.value,
  150. !!json.hasRunningInstance,
  151. !!json.hasQueuedInstance
  152. )
  153. }
  154. updateRateLimitStatus()
  155. }
  156. function updateFromJson(json) {
  157. // Fields that should not be updated
  158. // title - as the callback URL relies on it
  159. // Update rate limiting if changed (parse datetime string)
  160. if (json.datetimeRateLimitExpires) {
  161. const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
  162. rateLimitExpires.value = date.getTime() / 1000
  163. updateRateLimitStatus()
  164. } else if (json.datetimeRateLimitExpires === '') {
  165. // Explicitly clear if empty string
  166. rateLimitExpires.value = 0
  167. updateRateLimitStatus()
  168. }
  169. }
  170. function updateRateLimitStatus() {
  171. if (rateLimitExpires.value === 0) {
  172. isRateLimited.value = false
  173. rateLimitMessage.value = ''
  174. if (rateLimitInterval.value) {
  175. clearInterval(rateLimitInterval.value)
  176. rateLimitInterval.value = null
  177. }
  178. return
  179. }
  180. const now = Math.floor(Date.now() / 1000)
  181. const expires = rateLimitExpires.value
  182. if (now >= expires) {
  183. // Rate limit has expired
  184. isRateLimited.value = false
  185. rateLimitMessage.value = ''
  186. rateLimitExpires.value = 0
  187. if (rateLimitInterval.value) {
  188. clearInterval(rateLimitInterval.value)
  189. rateLimitInterval.value = null
  190. }
  191. } else {
  192. // Still rate limited
  193. isRateLimited.value = true
  194. const secondsRemaining = expires - now
  195. rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
  196. // Set up interval to update every second
  197. if (!rateLimitInterval.value) {
  198. rateLimitInterval.value = setInterval(() => {
  199. updateRateLimitStatus()
  200. }, 1000)
  201. }
  202. }
  203. }
  204. function openActionDetails() {
  205. const id = props.actionData?.bindingId
  206. if (!id) {
  207. return
  208. }
  209. router.push(`/action/${id}`)
  210. }
  211. async function handleClick() {
  212. if (popupOnStart.value === 'history') {
  213. openActionDetails()
  214. return
  215. }
  216. if (needsArgumentForm(props.actionData)) {
  217. router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
  218. } else {
  219. await startAction()
  220. }
  221. }
  222. function getUniqueId() {
  223. if (window.isSecureContext) {
  224. return window.crypto.randomUUID()
  225. } else {
  226. return Date.now().toString()
  227. }
  228. }
  229. async function pollExecutionUntilDone (trackingId) {
  230. const pollIntervalMs = 500
  231. const pollTimeoutMs = 10 * 60 * 1000
  232. const deadline = Date.now() + pollTimeoutMs
  233. while (Date.now() < deadline && isComponentMounted.value) {
  234. try {
  235. const result = await window.client.executionStatus({ executionTrackingId: trackingId })
  236. if (!isComponentMounted.value) {
  237. return
  238. }
  239. if (result.logEntry) {
  240. applyExecutionLogEntry(result.logEntry)
  241. if (result.logEntry.executionFinished) {
  242. return
  243. }
  244. }
  245. } catch (err) {
  246. console.error('Failed to poll execution status:', err)
  247. }
  248. if (!isComponentMounted.value) {
  249. return
  250. }
  251. await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
  252. }
  253. }
  254. async function startAction(actionArgs) {
  255. buttonClasses.value = [] // Removes old animation classes
  256. if (actionArgs === undefined) {
  257. actionArgs = []
  258. }
  259. // UUIDs are create client side, so that we can setup a "execution-button"
  260. // to track the execution before we send the request to the server.
  261. const startActionArgs = {
  262. bindingId: props.actionData.bindingId,
  263. arguments: actionArgs,
  264. uniqueTrackingId: getUniqueId()
  265. }
  266. console.log('Watching buttonResults for', startActionArgs.uniqueTrackingId)
  267. watch(
  268. () => buttonResults[startActionArgs.uniqueTrackingId],
  269. (newResult, oldResult) => {
  270. onLogEntryChanged(newResult)
  271. }
  272. )
  273. requestReconnectNow()
  274. try {
  275. const response = await window.client.startAction(startActionArgs)
  276. const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId
  277. if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
  278. router.push(`/logs/${trackingId}`)
  279. }
  280. if (!connectionState.connected) {
  281. await pollExecutionUntilDone(trackingId)
  282. }
  283. } catch (err) {
  284. console.error('Failed to start action:', err)
  285. }
  286. }
  287. function onLogEntryChanged(logEntry) {
  288. if (logEntry.executionFinished) {
  289. onExecutionFinished(logEntry)
  290. } else if (logEntry.queued && !logEntry.executionStarted) {
  291. onExecutionQueued(logEntry)
  292. } else {
  293. onExecutionStarted(logEntry)
  294. }
  295. }
  296. function onExecutionQueued(_logEntry) {
  297. isDisabled.value = true
  298. updateDom('action-queued', '[Queued]')
  299. }
  300. function onExecutionStarted(logEntry) {
  301. if (
  302. popupOnStart.value &&
  303. popupOnStart.value.includes('execution-dialog') &&
  304. !shouldSuppressPopupOnStartNavigation(router)
  305. ) {
  306. router.push(`/logs/${logEntry.executionTrackingId}`)
  307. }
  308. isDisabled.value = true
  309. updateDom(null, title.value)
  310. }
  311. function onExecutionFinished(logEntry) {
  312. if (logEntry.timedOut) {
  313. renderExecutionResult('action-timeout', 'Timed out')
  314. } else if (logEntry.blocked) {
  315. renderExecutionResult('action-blocked', 'Blocked!')
  316. } else if (logEntry.exitCode !== 0) {
  317. renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
  318. } else {
  319. const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
  320. renderExecutionResult('action-success', 'Success!')
  321. }
  322. }
  323. function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
  324. updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
  325. onExecStatusChanged()
  326. }
  327. function updateDom(resultCssClass, newTitle) {
  328. if (resultCssClass == null) {
  329. buttonClasses.value = []
  330. } else {
  331. buttonClasses.value = [resultCssClass]
  332. }
  333. displayTitle.value = newTitle
  334. }
  335. function onExecStatusChanged() {
  336. isDisabled.value = false
  337. setTimeout(() => {
  338. updateDom(null, title.value)
  339. }, 2000)
  340. }
  341. onMounted(() => {
  342. constructFromJson(props.actionData)
  343. // Watch the central rate limit store for updates to this button's bindingId
  344. // Watch the entire rateLimits object to ensure reactivity with dynamic keys
  345. watch(
  346. rateLimits,
  347. () => {
  348. const id = bindingId.value
  349. if (id && rateLimits[id] !== undefined) {
  350. const newExpires = rateLimits[id]
  351. if (newExpires !== rateLimitExpires.value) {
  352. rateLimitExpires.value = newExpires
  353. updateRateLimitStatus()
  354. }
  355. }
  356. },
  357. { deep: true }
  358. )
  359. })
  360. onUnmounted(() => {
  361. isComponentMounted.value = false
  362. if (rateLimitInterval.value) {
  363. clearInterval(rateLimitInterval.value)
  364. rateLimitInterval.value = null
  365. }
  366. })
  367. watch(
  368. () => props.actionData,
  369. (newData) => {
  370. updateFromJson(newData)
  371. if (newData?.icon !== undefined) {
  372. glyph.value = newData.icon ?? ''
  373. }
  374. },
  375. { deep: true }
  376. )
  377. defineExpose({
  378. glyph
  379. })
  380. </script>
  381. <style>
  382. @layer components {
  383. .action-button {
  384. display: flex;
  385. flex-direction: column;
  386. flex-grow: 1;
  387. position: relative;
  388. }
  389. .execution-indicator {
  390. position: absolute;
  391. top: 0.45em;
  392. left: 0.45em;
  393. width: 0.65em;
  394. height: 0.65em;
  395. border-radius: 50%;
  396. z-index: 1;
  397. pointer-events: none;
  398. }
  399. .execution-indicator-running {
  400. background: #28a745;
  401. }
  402. .execution-indicator-queued {
  403. background: #0d6efd;
  404. }
  405. .action-button button {
  406. display: flex;
  407. flex-direction: column;
  408. flex-grow: 1;
  409. justify-content: center;
  410. padding: 0.5em;
  411. border: 1px solid #ccc;
  412. border-radius: 4px;
  413. background: #fff;
  414. cursor: pointer;
  415. transition: all 0.2s ease;
  416. box-shadow: 0 0 .6em #aaa;
  417. font-size: .85em;
  418. border-radius: .7em;
  419. }
  420. .action-button button:hover:not(:disabled) {
  421. background: #f5f5f5;
  422. border-color: #999;
  423. }
  424. .action-button button:disabled {
  425. opacity: 0.6;
  426. cursor: not-allowed;
  427. }
  428. .action-button button .icon {
  429. font-size: 3em;
  430. flex-grow: 1;
  431. align-content: center;
  432. }
  433. .action-button button .title {
  434. font-weight: 500;
  435. padding: 0.2em;
  436. }
  437. .action-button button .rate-limit-message {
  438. font-size: 0.75em;
  439. color: #856404;
  440. padding: 0.2em;
  441. font-weight: normal;
  442. }
  443. /* Animation classes */
  444. .action-button button.action-timeout {
  445. background: #fff3cd;
  446. border-color: #ffeaa7;
  447. color: #856404;
  448. }
  449. .action-button button.action-blocked {
  450. background: #f8d7da !important;
  451. border-color: #f5c6cb;
  452. color: #721c24;
  453. }
  454. .action-button button.action-queued {
  455. background: #e7f1ff !important;
  456. border-color: #9ec5fe;
  457. color: #084298;
  458. }
  459. .action-button button.action-nonzero-exit {
  460. background: #f8d7da !important;
  461. border-color: #f5c6cb;
  462. color: #721c24;
  463. }
  464. .action-button button.action-success {
  465. background: #d4edda !important;
  466. border-color: #c3e6cb;
  467. color: #155724;
  468. }
  469. .action-button-footer {
  470. margin-top: 0.5em;
  471. }
  472. .navigate-on-start-container {
  473. position: relative;
  474. margin-left: auto;
  475. height: 0;
  476. right: 0;
  477. top: 0;
  478. }
  479. @media (prefers-color-scheme: dark) {
  480. .action-button button {
  481. background: #111;
  482. border-color: #000;
  483. box-shadow: 0 0 6px #000;
  484. color: #fff;
  485. }
  486. .action-button button:hover:not(:disabled) {
  487. background: #222;
  488. border-color: #000;
  489. box-shadow: 0 0 6px #444;
  490. color: #fff;
  491. }
  492. }
  493. }
  494. </style>