Files
ComfyUI_frontend/src/components/queue/job/JobDetailsPopover.vue
Benjamin Lu 85bec5ee47 Use shared button components in queue overlay (#6793)
## Summary
- replace raw button elements in queue progress overlay UI with shared
IconButton/TextButton/IconTextButton components
- remove forced justify-start from IconTextButton base and add explicit
alignment where needed
- keep queue overlay actions consistent with button styling patterns

## Testing
- pnpm typecheck
- pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6793-Use-shared-button-components-in-queue-overlay-2b26d73d3650814d9ebfebba74226036)
by [Unito](https://www.unito.io)
2025-11-20 20:18:33 -07:00

360 lines
11 KiB
Vue

<template>
<div
class="w-[300px] min-w-[260px] rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-md"
>
<div class="flex items-center border-b border-interface-stroke p-4">
<span
class="text-[0.875rem] leading-normal font-normal text-text-primary"
>{{ t('queue.jobDetails.header') }}</span
>
</div>
<div class="flex flex-col gap-6 px-4 pt-4 pb-4">
<div class="grid grid-cols-2 items-center gap-x-2 gap-y-2">
<template v-for="row in baseRows" :key="row.label">
<div
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
>
{{ row.label }}
</div>
<div
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
<IconButton
v-if="row.canCopy"
type="transparent"
size="sm"
class="ml-2 size-6 bg-transparent hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyJobId"
>
<i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/>
</IconButton>
</div>
</template>
</div>
<div
v-if="extraRows.length"
class="grid grid-cols-2 items-center gap-x-2 gap-y-2"
>
<template v-for="row in extraRows" :key="row.label">
<div
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
>
{{ row.label }}
</div>
<div
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
</div>
</template>
</div>
<div v-if="jobState === 'failed'" class="grid grid-cols-2 gap-x-2">
<div
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
>
{{ t('queue.jobDetails.errorMessage') }}
</div>
<div class="flex items-center justify-between gap-4">
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="copyAriaLabel"
:aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage"
>
<template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</template>
</IconTextButton>
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="t('queue.jobDetails.report')"
icon-position="right"
@click.stop="reportJobError"
>
<template #icon>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</template>
</IconTextButton>
</div>
<div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
>
{{ errorMessageValue }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import { formatClockTime } from '@/utils/dateTimeUtil'
import { jobStateFromTask } from '@/utils/queueUtil'
import { useJobErrorReporting } from './useJobErrorReporting'
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
const props = defineProps<{
jobId: string
workflowId?: string
}>()
const copyAriaLabel = computed(() => t('g.copy'))
const workflowStore = useWorkflowStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const dialog = useDialogService()
const { locale } = useI18n()
const workflowValue = computed(() => {
const wid = props.workflowId
if (!wid) return ''
const activeId = workflowStore.activeWorkflow?.activeState?.id
if (activeId && activeId === wid) {
return workflowStore.activeWorkflow?.filename ?? wid
}
return wid
})
const jobIdValue = computed(() => props.jobId)
const { copyToClipboard } = useCopyToClipboard()
const copyJobId = () => void copyToClipboard(jobIdValue.value)
const taskForJob = computed(() => {
const pid = props.jobId
const findIn = (arr: TaskItemImpl[]) =>
arr.find((t) => String(t.promptId ?? '') === String(pid))
return (
findIn(queueStore.pendingTasks) ||
findIn(queueStore.runningTasks) ||
findIn(queueStore.historyTasks) ||
null
)
})
const jobState = computed(() => {
const task = taskForJob.value
if (!task) return null
const isInitializing = executionStore.isPromptInitializing(
String(task?.promptId)
)
return jobStateFromTask(task, isInitializing)
})
const firstSeenTs = computed<number | undefined>(() => {
const task = taskForJob.value
return task?.createTime
})
const queuedAtValue = computed(() =>
firstSeenTs.value !== undefined
? formatClockTime(firstSeenTs.value, locale.value)
: ''
)
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.queueIndex) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.queueIndex) < idx
)
return ahead.length
})
const queuePositionValue = computed(() => {
if (jobsAhead.value == null) return ''
const n = jobsAhead.value
return t('queue.jobDetails.queuePositionValue', { count: n }, n)
})
const nowTs = ref<number>(Date.now())
let timer: number | null = null
onMounted(() => {
timer = window.setInterval(() => {
nowTs.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (timer != null) {
clearInterval(timer)
timer = null
}
})
const {
showParallelQueuedStats,
estimateRangeSeconds,
estimateRemainingRangeSeconds,
timeElapsedValue
} = useQueueEstimates({
queueStore,
executionStore,
taskForJob,
jobState,
firstSeenTs,
jobsAhead,
nowTs
})
const formatEta = (lo: number, hi: number): string => {
if (hi <= 60) {
const hiS = Math.max(1, Math.round(hi))
const loS = Math.max(1, Math.min(hiS, Math.round(lo)))
if (loS === hiS)
return t('queue.jobDetails.eta.seconds', { count: hiS }, hiS)
return t('queue.jobDetails.eta.secondsRange', { lo: loS, hi: hiS })
}
if (lo >= 60 && hi < 90) {
return t('queue.jobDetails.eta.minutes', { count: 1 }, 1)
}
const loM = Math.max(1, Math.floor(lo / 60))
const hiM = Math.max(loM, Math.ceil(hi / 60))
if (loM === hiM) {
return t('queue.jobDetails.eta.minutes', { count: loM }, loM)
}
return t('queue.jobDetails.eta.minutesRange', { lo: loM, hi: hiM })
}
const estimatedStartInValue = computed(() => {
const range = estimateRangeSeconds.value
if (!range) return ''
const [lo, hi] = range
return formatEta(lo, hi)
})
const estimatedFinishInValue = computed(() => {
const range = estimateRemainingRangeSeconds.value
if (!range) return ''
const [lo, hi] = range
return formatEta(lo, hi)
})
type DetailRow = { label: string; value: string; canCopy?: boolean }
const baseRows = computed<DetailRow[]>(() => [
{ label: t('queue.jobDetails.workflow'), value: workflowValue.value },
{ label: t('queue.jobDetails.jobId'), value: jobIdValue.value, canCopy: true }
])
const extraRows = computed<DetailRow[]>(() => {
if (jobState.value === 'pending') {
if (!firstSeenTs.value) return []
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value }
]
if (showParallelQueuedStats.value) {
rows.push(
{
label: t('queue.jobDetails.queuePosition'),
value: queuePositionValue.value
},
{
label: t('queue.jobDetails.timeElapsed'),
value: timeElapsedValue.value
},
{
label: t('queue.jobDetails.estimatedStartIn'),
value: estimatedStartInValue.value
}
)
}
return rows
}
if (jobState.value === 'running') {
if (!firstSeenTs.value) return []
return [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{
label: t('queue.jobDetails.timeElapsed'),
value: timeElapsedValue.value
},
{
label: t('queue.jobDetails.estimatedFinishIn'),
value: estimatedFinishInValue.value
}
]
}
if (jobState.value === 'completed') {
const task = taskForJob.value as any
const endTs: number | undefined = task?.executionEndTimestamp
const execMs: number | undefined = task?.executionTime
const generatedOnValue = endTs ? formatClockTime(endTs, locale.value) : ''
const totalGenTimeValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
{
label: t('queue.jobDetails.totalGenerationTime'),
value: totalGenTimeValue
}
]
if (isCloud) {
rows.push({
label: t('queue.jobDetails.computeHoursUsed'),
value: computeHoursValue
})
}
return rows
}
if (jobState.value === 'failed') {
const task = taskForJob.value as any
const execMs: number | undefined = task?.executionTime
const failedAfterValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }
]
if (isCloud) {
rows.push({
label: t('queue.jobDetails.computeHoursUsed'),
value: computeHoursValue
})
}
return rows
}
return []
})
const { errorMessageValue, copyErrorMessage, reportJobError } =
useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog
})
</script>