ActionButton.vue 14 KB

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