| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- <template>
- <div :id="`actionButton-${bindingId}`" role="none" class="action-button">
- <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 == ''" class="navigate-on-start" title="Run in the background">
- <HugeiconsIcon :icon="WorkoutRunIcon" />
- </div>
- </div>
- <span class="icon" v-html="unicodeIcon"></span>
- <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 { useRouter } from 'vue-router'
- import { HugeiconsIcon } from '@hugeicons/vue'
- import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
- import { ref, watch, onMounted, onUnmounted, inject, 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 unicodeIcon = ref('💩')
- 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('')
- let rateLimitInterval = null
- // Animation classes
- const buttonClasses = ref([])
- // Show navigate on start icons - defaults to true if not set
- const showNavigateOnStartIcons = computed(() => {
- return window.initResponse?.showNavigateOnStartIcons ?? true
- })
- // Combined classes including custom cssClass
- const combinedClasses = computed(() => {
- const classes = [...buttonClasses.value]
- if (props.cssClass) {
- classes.push(props.cssClass)
- }
- return classes
- })
- // Timestamps
- const updateIterationTimestamp = ref(0)
- function getUnicodeIcon(icon) {
- if (icon === '') {
- console.log('icon not found ', icon)
- return '💩'
- } else {
- return unescape(icon)
- }
- }
- 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 (props.actionData.arguments.length > 0) {
- navigateOnStart.value = 'arg'
- }
- isDisabled.value = !json.canExec
- displayTitle.value = title.value
- unicodeIcon.value = getUnicodeIcon(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
- }
- updateRateLimitStatus()
- }
- function updateFromJson(json) {
- // Fields that should not be updated
- // title - as the callback URL relies on it
- unicodeIcon.value = getUnicodeIcon(json.icon)
- // 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) {
- clearInterval(rateLimitInterval)
- rateLimitInterval = 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) {
- clearInterval(rateLimitInterval)
- rateLimitInterval = 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) {
- rateLimitInterval = setInterval(() => {
- updateRateLimitStatus()
- }, 1000)
- }
- }
- }
- async function handleClick() {
- if (props.actionData.arguments && props.actionData.arguments.length > 0) {
- 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 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)
- }
- )
- try {
- await window.client.startAction(startActionArgs)
- } catch (err) {
- console.error('Failed to start action:', err)
- }
- }
- function onLogEntryChanged(logEntry) {
- if (logEntry.executionFinished) {
- onExecutionFinished(logEntry)
- } else {
- onExecutionStarted(logEntry)
- }
- }
- function onExecutionStarted(logEntry) {
- if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
- router.push(`/logs/${logEntry.executionTrackingId}`)
- }
- isDisabled.value = true
- }
- 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(() => {
- if (rateLimitInterval) {
- clearInterval(rateLimitInterval)
- rateLimitInterval = null
- }
- })
- watch(
- () => props.actionData,
- (newData) => {
- updateFromJson(newData)
- },
- { deep: true }
- )
- </script>
- <style>
- @layer components {
- .action-button {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- }
- .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-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>
|