580 lines
14 KiB
Vue
580 lines
14 KiB
Vue
<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>
|