| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579 |
- <template>
- <div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
- <span
- v-if="showExecutionIndicator"
- class="execution-indicator"
- :class="executionIndicatorClass"
- :title="executionIndicatorTitle"
- aria-hidden="true"
- ></span>
- <button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
- :class="combinedClasses" @click="handleClick">
- <div v-if="showNavigateOnStartIcons" class="navigate-on-start-container">
- <div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
- <HugeiconsIcon :icon="ComputerTerminal01Icon" />
- </div>
- <div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
- <HugeiconsIcon :icon="TypeCursorIcon" />
- </div>
- <div v-if="navigateOnStart == 'hist'" class="navigate-on-start" title="Opens action execution history on start">
- <HugeiconsIcon :icon="WorkHistoryIcon" />
- </div>
- <div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
- <HugeiconsIcon :icon="WorkoutRunIcon" />
- </div>
- </div>
- <ActionIconGlyph class="icon" :glyph="actionGlyph" />
- <span class="title" aria-live="polite">{{ displayTitle }}
- </span>
- <span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
- </button>
- </div>
- </template>
- <script setup>
- import { buttonResults } from './stores/buttonResults'
- import { rateLimits } from './stores/rateLimits'
- import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
- import { connectionState } from './stores/connectionState'
- import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
- import { useRouter } from 'vue-router'
- import { needsArgumentForm } from './utils/needsArgumentForm.js'
- import { shouldSuppressPopupOnStartNavigation } from './utils/popupOnStartNavigation.js'
- import { HugeiconsIcon } from '@hugeicons/vue'
- import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
- import ActionIconGlyph from './components/ActionIconGlyph.vue'
- import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
- const router = useRouter()
- const navigateOnStart = ref('')
- const props = defineProps({
- actionData: {
- type: Object,
- required: true
- },
- cssClass: {
- type: String,
- required: false,
- default: ''
- }
- })
- const bindingId = ref('')
- const title = ref('')
- const canExec = ref(true)
- const popupOnStart = ref('')
- // Display properties
- const displayTitle = ref('')
- // State
- const isDisabled = ref(false)
- const showArgumentForm = ref(false)
- // Rate limiting
- const rateLimitExpires = ref(0)
- const isRateLimited = ref(false)
- const rateLimitMessage = ref('')
- const rateLimitInterval = ref(null)
- const isComponentMounted = ref(true)
- // Animation classes
- const buttonClasses = ref([])
- // Show navigate on start icons - defaults to true if not set
- const showNavigateOnStartIcons = computed(() => {
- return window.initResponse?.showNavigateOnStartIcons ?? true
- })
- const actionGlyph = computed(() => props.actionData?.icon ?? '')
- const glyph = ref('')
- // Combined classes including custom cssClass
- const combinedClasses = computed(() => {
- const classes = [...buttonClasses.value]
- if (props.cssClass) {
- classes.push(props.cssClass)
- }
- return classes
- })
- const hasRunningInstance = computed(() => {
- const id = bindingId.value
- return !!(id && bindingExecutionState[id]?.hasRunning)
- })
- const hasQueuedInstance = computed(() => {
- const id = bindingId.value
- return !!(id && bindingExecutionState[id]?.hasQueued)
- })
- const showExecutionIndicator = computed(() => {
- return hasRunningInstance.value || hasQueuedInstance.value
- })
- const executionIndicatorClass = computed(() => {
- if (hasRunningInstance.value) {
- return 'execution-indicator-running'
- }
- if (hasQueuedInstance.value) {
- return 'execution-indicator-queued'
- }
- return ''
- })
- const executionIndicatorTitle = computed(() => {
- if (hasRunningInstance.value) {
- return 'Running'
- }
- if (hasQueuedInstance.value) {
- return 'Queued'
- }
- return ''
- })
- // Timestamps
- const updateIterationTimestamp = ref(0)
- function constructFromJson(json) {
- updateIterationTimestamp.value = 0
- updateFromJson(json)
- bindingId.value = json.bindingId
- title.value = json.title
- canExec.value = json.canExec
- popupOnStart.value = json.popupOnStart
- if (popupOnStart.value.includes('execution-dialog')) {
- navigateOnStart.value = 'pop'
- } else if (popupOnStart.value === 'history') {
- navigateOnStart.value = 'hist'
- } else if (needsArgumentForm(props.actionData)) {
- navigateOnStart.value = 'arg'
- }
- isDisabled.value = !json.canExec
- displayTitle.value = title.value
- glyph.value = json.icon ?? ''
- // Initialize rate limit from action data (parse datetime string)
- if (json.datetimeRateLimitExpires) {
- const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
- rateLimitExpires.value = date.getTime() / 1000
- } else {
- rateLimitExpires.value = 0
- }
- // Also initialize the store so the watch picks it up
- if (bindingId.value) {
- rateLimits[bindingId.value] = rateLimitExpires.value
- setBindingExecutionState(
- bindingId.value,
- !!json.hasRunningInstance,
- !!json.hasQueuedInstance
- )
- }
- updateRateLimitStatus()
- }
- function updateFromJson(json) {
- // Fields that should not be updated
- // title - as the callback URL relies on it
- // Update rate limiting if changed (parse datetime string)
- if (json.datetimeRateLimitExpires) {
- const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
- rateLimitExpires.value = date.getTime() / 1000
- updateRateLimitStatus()
- } else if (json.datetimeRateLimitExpires === '') {
- // Explicitly clear if empty string
- rateLimitExpires.value = 0
- updateRateLimitStatus()
- }
- }
- function updateRateLimitStatus() {
- if (rateLimitExpires.value === 0) {
- isRateLimited.value = false
- rateLimitMessage.value = ''
- if (rateLimitInterval.value) {
- clearInterval(rateLimitInterval.value)
- rateLimitInterval.value = null
- }
- return
- }
- const now = Math.floor(Date.now() / 1000)
- const expires = rateLimitExpires.value
- if (now >= expires) {
- // Rate limit has expired
- isRateLimited.value = false
- rateLimitMessage.value = ''
- rateLimitExpires.value = 0
- if (rateLimitInterval.value) {
- clearInterval(rateLimitInterval.value)
- rateLimitInterval.value = null
- }
- } else {
- // Still rate limited
- isRateLimited.value = true
- const secondsRemaining = expires - now
- rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
- // Set up interval to update every second
- if (!rateLimitInterval.value) {
- rateLimitInterval.value = setInterval(() => {
- updateRateLimitStatus()
- }, 1000)
- }
- }
- }
- function openActionDetails() {
- const id = props.actionData?.bindingId
- if (!id) {
- return
- }
- router.push(`/action/${id}`)
- }
- async function handleClick() {
- if (popupOnStart.value === 'history') {
- openActionDetails()
- return
- }
- if (needsArgumentForm(props.actionData)) {
- router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
- } else {
- await startAction()
- }
- }
- function getUniqueId() {
- if (window.isSecureContext) {
- return window.crypto.randomUUID()
- } else {
- return Date.now().toString()
- }
- }
- async function pollExecutionUntilDone (trackingId) {
- const pollIntervalMs = 500
- const pollTimeoutMs = 10 * 60 * 1000
- const deadline = Date.now() + pollTimeoutMs
- while (Date.now() < deadline && isComponentMounted.value) {
- try {
- const result = await window.client.executionStatus({ executionTrackingId: trackingId })
- if (!isComponentMounted.value) {
- return
- }
- if (result.logEntry) {
- applyExecutionLogEntry(result.logEntry)
- if (result.logEntry.executionFinished) {
- return
- }
- }
- } catch (err) {
- console.error('Failed to poll execution status:', err)
- }
- if (!isComponentMounted.value) {
- return
- }
- await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
- }
- }
- async function startAction(actionArgs) {
- buttonClasses.value = [] // Removes old animation classes
- if (actionArgs === undefined) {
- actionArgs = []
- }
- // UUIDs are create client side, so that we can setup a "execution-button"
- // to track the execution before we send the request to the server.
- const startActionArgs = {
- bindingId: props.actionData.bindingId,
- arguments: actionArgs,
- uniqueTrackingId: getUniqueId()
- }
- console.log('Watching buttonResults for', startActionArgs.uniqueTrackingId)
- watch(
- () => buttonResults[startActionArgs.uniqueTrackingId],
- (newResult, oldResult) => {
- onLogEntryChanged(newResult)
- }
- )
- requestReconnectNow()
- try {
- const response = await window.client.startAction(startActionArgs)
- const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId
- if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
- router.push(`/logs/${trackingId}`)
- }
- if (!connectionState.connected) {
- await pollExecutionUntilDone(trackingId)
- }
- } catch (err) {
- console.error('Failed to start action:', err)
- }
- }
- function onLogEntryChanged(logEntry) {
- if (logEntry.executionFinished) {
- onExecutionFinished(logEntry)
- } else if (logEntry.queued && !logEntry.executionStarted) {
- onExecutionQueued(logEntry)
- } else {
- onExecutionStarted(logEntry)
- }
- }
- function onExecutionQueued(_logEntry) {
- isDisabled.value = true
- updateDom('action-queued', '[Queued]')
- }
- function onExecutionStarted(logEntry) {
- if (
- popupOnStart.value &&
- popupOnStart.value.includes('execution-dialog') &&
- !shouldSuppressPopupOnStartNavigation(router)
- ) {
- router.push(`/logs/${logEntry.executionTrackingId}`)
- }
- isDisabled.value = true
- updateDom(null, title.value)
- }
- function onExecutionFinished(logEntry) {
- if (logEntry.timedOut) {
- renderExecutionResult('action-timeout', 'Timed out')
- } else if (logEntry.blocked) {
- renderExecutionResult('action-blocked', 'Blocked!')
- } else if (logEntry.exitCode !== 0) {
- renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
- } else {
- const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
- renderExecutionResult('action-success', 'Success!')
- }
- }
- function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
- updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
- onExecStatusChanged()
- }
- function updateDom(resultCssClass, newTitle) {
- if (resultCssClass == null) {
- buttonClasses.value = []
- } else {
- buttonClasses.value = [resultCssClass]
- }
- displayTitle.value = newTitle
- }
- function onExecStatusChanged() {
- isDisabled.value = false
- setTimeout(() => {
- updateDom(null, title.value)
- }, 2000)
- }
- onMounted(() => {
- constructFromJson(props.actionData)
- // Watch the central rate limit store for updates to this button's bindingId
- // Watch the entire rateLimits object to ensure reactivity with dynamic keys
- watch(
- rateLimits,
- () => {
- const id = bindingId.value
- if (id && rateLimits[id] !== undefined) {
- const newExpires = rateLimits[id]
- if (newExpires !== rateLimitExpires.value) {
- rateLimitExpires.value = newExpires
- updateRateLimitStatus()
- }
- }
- },
- { deep: true }
- )
- })
- onUnmounted(() => {
- isComponentMounted.value = false
- if (rateLimitInterval.value) {
- clearInterval(rateLimitInterval.value)
- rateLimitInterval.value = null
- }
- })
- watch(
- () => props.actionData,
- (newData) => {
- updateFromJson(newData)
- if (newData?.icon !== undefined) {
- glyph.value = newData.icon ?? ''
- }
- },
- { deep: true }
- )
- defineExpose({
- glyph
- })
- </script>
- <style>
- @layer components {
- .action-button {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- position: relative;
- }
- .execution-indicator {
- position: absolute;
- top: 0.45em;
- left: 0.45em;
- width: 0.65em;
- height: 0.65em;
- border-radius: 50%;
- z-index: 1;
- pointer-events: none;
- }
- .execution-indicator-running {
- background: #28a745;
- }
- .execution-indicator-queued {
- background: #0d6efd;
- }
- .action-button button {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- justify-content: center;
- padding: 0.5em;
- border: 1px solid #ccc;
- border-radius: 4px;
- background: #fff;
- cursor: pointer;
- transition: all 0.2s ease;
- box-shadow: 0 0 .6em #aaa;
- font-size: .85em;
- border-radius: .7em;
- }
- .action-button button:hover:not(:disabled) {
- background: #f5f5f5;
- border-color: #999;
- }
- .action-button button:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
- .action-button button .icon {
- font-size: 3em;
- flex-grow: 1;
- align-content: center;
- }
- .action-button button .title {
- font-weight: 500;
- padding: 0.2em;
- }
- .action-button button .rate-limit-message {
- font-size: 0.75em;
- color: #856404;
- padding: 0.2em;
- font-weight: normal;
- }
- /* Animation classes */
- .action-button button.action-timeout {
- background: #fff3cd;
- border-color: #ffeaa7;
- color: #856404;
- }
- .action-button button.action-blocked {
- background: #f8d7da !important;
- border-color: #f5c6cb;
- color: #721c24;
- }
- .action-button button.action-queued {
- background: #e7f1ff !important;
- border-color: #9ec5fe;
- color: #084298;
- }
- .action-button button.action-nonzero-exit {
- background: #f8d7da !important;
- border-color: #f5c6cb;
- color: #721c24;
- }
- .action-button button.action-success {
- background: #d4edda !important;
- border-color: #c3e6cb;
- color: #155724;
- }
- .action-button-footer {
- margin-top: 0.5em;
- }
- .navigate-on-start-container {
- position: relative;
- margin-left: auto;
- height: 0;
- right: 0;
- top: 0;
- }
- @media (prefers-color-scheme: dark) {
- .action-button button {
- background: #111;
- border-color: #000;
- box-shadow: 0 0 6px #000;
- color: #fff;
- }
- .action-button button:hover:not(:disabled) {
- background: #222;
- border-color: #000;
- box-shadow: 0 0 6px #444;
- color: #fff;
- }
- }
- }
- </style>
|