Implement workflow progress panel (#6092)

Adds a workflow progress panel component underneath the
`actionbar-container`.

I suggest starting a review at the extraneous changes that were needed.
Including but not limited to:

- `get createTime()` in queueStore
- `promptIdToWorkflowId`, `initializingPromptIds`, and
`nodeProgressStatesByPrompt` in executionStore
- `create_time` handling in v2ToV1Adapter
- `pointer-events-auto` on ComfyActionbar.vue

The rest of the changes should be contained under
`QueueProgressOverlay.vue`, and has less of a blast radius in case
something goes wrong.

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
This commit is contained in:
Benjamin Lu
2025-11-18 22:43:49 -08:00
committed by GitHub
parent 92968f3f9b
commit e42715086e
76 changed files with 7117 additions and 39 deletions

View File

@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const meta: Meta<typeof CompletionSummaryBanner> = {
title: 'Queue/CompletionSummaryBanner',
component: CompletionSummaryBanner,
parameters: {
layout: 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
export const AllSuccessSingle: Story = {
args: {
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: [thumbs[0]]
}
}
export const AllSuccessPlural: Story = {
args: {
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: thumbs
}
}
export const MixedSingleSingle: Story = {
args: {
mode: 'mixed',
completedCount: 1,
failedCount: 1,
thumbnailUrls: thumbs.slice(0, 2)
}
}
export const MixedPluralPlural: Story = {
args: {
mode: 'mixed',
completedCount: 2,
failedCount: 3,
thumbnailUrls: thumbs
}
}
export const AllFailedSingle: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 1,
thumbnailUrls: []
}
}
export const AllFailedPlural: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 4,
thumbnailUrls: []
}
}

View File

@@ -0,0 +1,91 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
jobsCompleted: '{count} job completed | {count} jobs completed',
jobsFailed: '{count} job failed | {count} jobs failed'
}
}
}
}
})
const mountComponent = (props: Record<string, unknown>) =>
mount(CompletionSummaryBanner, {
props: {
mode: 'allSuccess',
completedCount: 0,
failedCount: 0,
...props
},
global: {
plugins: [i18n]
}
})
describe('CompletionSummaryBanner', () => {
it('renders success mode text, thumbnails, and aria label', () => {
const wrapper = mountComponent({
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: [
'https://example.com/thumb-a.png',
'https://example.com/thumb-b.png'
],
ariaLabel: 'Open queue summary'
})
const button = wrapper.get('button')
expect(button.attributes('aria-label')).toBe('Open queue summary')
expect(wrapper.text()).toContain('3 jobs completed')
const thumbnailImages = wrapper.findAll('img')
expect(thumbnailImages).toHaveLength(2)
expect(thumbnailImages[0].attributes('src')).toBe(
'https://example.com/thumb-a.png'
)
expect(thumbnailImages[1].attributes('src')).toBe(
'https://example.com/thumb-b.png'
)
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
expect(thumbnailContainers[1].attributes('style')).toContain(
'margin-left: -12px'
)
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
})
it('renders mixed mode with success and failure counts', () => {
const wrapper = mountComponent({
mode: 'mixed',
completedCount: 2,
failedCount: 1
})
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
expect(summaryText).toContain('2 jobs completed, 1 job failed')
})
it('renders failure mode icon without thumbnails', () => {
const wrapper = mountComponent({
mode: 'allFailed',
completedCount: 0,
failedCount: 4
})
expect(wrapper.text()).toContain('4 jobs failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
expect(wrapper.findAll('img')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,99 @@
<template>
<button
type="button"
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
<i
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none"
:class="'text-destructive-background'"
/>
</span>
<span class="inline-flex items-center gap-2">
<span
v-if="props.mode !== 'allFailed'"
class="relative inline-flex h-6 items-center"
>
<span
v-for="(url, idx) in props.thumbnailUrls"
:key="url + idx"
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
>
<img :src="url" alt="preview" class="h-full w-full object-cover" />
</span>
</span>
<span class="text-[14px] font-normal text-text-primary">
<template v-if="props.mode === 'allSuccess'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else-if="props.mode === 'mixed'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
<span>, </span>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
</span>
</span>
</span>
<span
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</button>
</template>
<script setup lang="ts">
import type {
CompletionSummary,
CompletionSummaryMode
} from '@/composables/queue/useCompletionSummary'
type Props = {
mode: CompletionSummaryMode
completedCount: CompletionSummary['completedCount']
failedCount: CompletionSummary['failedCount']
thumbnailUrls?: CompletionSummary['thumbnailUrls']
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
</script>

View File

@@ -0,0 +1,125 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
total: 'Total: {percent}',
currentNode: 'Current node:',
running: 'running',
interruptAll: 'Interrupt all running jobs',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
viewAllJobs: 'View all jobs',
cancelJobTooltip: 'Cancel job',
clearQueueTooltip: 'Clear queue'
}
}
}
}
})
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
summaryRow: '.flex.items-center.gap-2',
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
}
const COPY = {
viewAllJobs: 'View all jobs'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row',
...props
},
global: {
plugins: [i18n],
directives: {
tooltip: tooltipDirectiveStub
}
}
})
describe('QueueOverlayActive', () => {
it('renders progress metrics and emits actions when buttons clicked', async () => {
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
const progressBars = wrapper.findAll('.absolute.inset-0')
expect(progressBars[0].attributes('style')).toContain('width: 65%')
expect(progressBars[1].attributes('style')).toContain('width: 40%')
const content = wrapper.text().replace(/\s+/g, ' ')
expect(content).toContain('Total: 65%')
const [runningSection, queuedSection] = wrapper.findAll(
SELECTORS.summaryRow
)
expect(runningSection.text()).toContain('2')
expect(runningSection.text()).toContain('running')
expect(queuedSection.text()).toContain('3')
expect(queuedSection.text()).toContain('queued')
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
expect(currentNodeSection.text()).toContain('Current node:')
expect(currentNodeSection.text()).toContain('Sampler')
expect(currentNodeSection.text()).toContain('40%')
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
await interruptButton.trigger('click')
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
const buttons = wrapper.findAll('button')
const viewAllButton = buttons.find((btn) =>
btn.text().includes(COPY.viewAllJobs)
)
expect(viewAllButton).toBeDefined()
await viewAllButton!.trigger('click')
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
})
it('hides action buttons when counts are zero', () => {
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')
})
})

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex flex-col gap-3 p-2">
<div class="flex flex-col gap-1">
<div
class="relative h-2 w-full overflow-hidden rounded-full border border-interface-stroke bg-interface-panel-surface"
>
<div
class="absolute inset-0 h-full rounded-full transition-[width]"
:style="totalProgressStyle"
/>
<div
class="absolute inset-0 h-full rounded-full transition-[width]"
:style="currentNodeProgressStyle"
/>
</div>
<div class="flex items-start justify-end gap-4 text-[12px] leading-none">
<div class="flex items-center gap-1 text-text-primary opacity-90">
<i18n-t keypath="sideToolbar.queueProgressOverlay.total">
<template #percent>
<span class="font-bold">{{ totalPercentFormatted }}</span>
</template>
</i18n-t>
</div>
<div class="flex items-center gap-1 text-text-secondary">
<span>{{ t('sideToolbar.queueProgressOverlay.currentNode') }}</span>
<span class="inline-block max-w-[10rem] truncate">{{
currentNodeName
}}</span>
<span class="flex items-center gap-1">
<span>{{ currentNodePercentFormatted }}</span>
</span>
</div>
</div>
</div>
<div :class="bottomRowClass">
<div class="flex items-center gap-4 text-[12px] text-text-primary">
<div class="flex items-center gap-2">
<span class="opacity-90">
<span class="font-bold">{{ runningCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<button
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</button>
</div>
<div class="flex items-center gap-2">
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<button
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</button>
</div>
</div>
<button
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
@click="$emit('viewAllJobs')"
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
totalProgressStyle: Record<string, string>
currentNodeProgressStyle: Record<string, string>
totalPercentFormatted: string
currentNodePercentFormatted: string
currentNodeName: string
runningCount: number
queuedCount: number
bottomRowClass: string
}>()
defineEmits<{
(e: 'interruptAll'): void
(e: 'clearQueued'): void
(e: 'viewAllJobs'): void
}>()
const { t } = useI18n()
const cancelJobTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
</script>

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
expandCollapsedQueue: 'Expand job queue',
noActiveJobs: 'No active jobs'
}
}
}
}
})
const CompletionSummaryBannerStub = {
name: 'CompletionSummaryBanner',
props: [
'mode',
'completedCount',
'failedCount',
'thumbnailUrls',
'ariaLabel'
],
emits: ['click'],
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
}
const mountComponent = (summary: CompletionSummary) =>
mount(QueueOverlayEmpty, {
props: { summary },
global: {
plugins: [i18n],
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
}
})
describe('QueueOverlayEmpty', () => {
it('renders completion summary banner and proxies click', async () => {
const summary: CompletionSummary = {
mode: 'mixed',
completedCount: 2,
failedCount: 1,
thumbnailUrls: ['thumb-a']
}
const wrapper = mountComponent(summary)
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
expect(summaryBanner.exists()).toBe(true)
expect(summaryBanner.props()).toMatchObject({
mode: 'mixed',
completedCount: 2,
failedCount: 1,
thumbnailUrls: ['thumb-a'],
ariaLabel: 'Expand job queue'
})
await summaryBanner.trigger('click')
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,27 @@
<template>
<div class="pointer-events-auto">
<CompletionSummaryBanner
:mode="summary.mode"
:completed-count="summary.completedCount"
:failed-count="summary.failedCount"
:thumbnail-urls="summary.thumbnailUrls"
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
@click="$emit('summaryClick')"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
defineProps<{ summary: CompletionSummary }>()
defineEmits<{
(e: 'summaryClick'): void
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="flex w-full flex-col gap-4">
<QueueOverlayHeader
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
@clear-history="$emit('clearHistory')"
/>
<div class="flex items-center justify-between px-3">
<button
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</button>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<button
v-if="queuedCount > 0"
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
</button>
</div>
</div>
<JobFiltersBar
:selected-job-tab="selectedJobTab"
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="flex-1 min-h-0 overflow-y-auto">
<JobGroupsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@view-item="$emit('viewItem', $event)"
@menu="onMenuItem"
/>
</div>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
import JobGroupsList from './job/JobGroupsList.vue'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
displayedJobGroups: JobGroup[]
hasFailedJobs: boolean
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'clearHistory'): void
(e: 'clearQueued'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
)
const onCancelItemEvent = (item: JobListItem) => {
emit('cancelItem', item)
}
const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
}
</script>

View File

@@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
const popoverToggleSpy = vi.fn()
const popoverHideSpy = vi.fn()
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
const toggle = (event: Event) => {
popoverToggleSpy(event)
}
const hide = () => {
popoverHideSpy()
}
expose({ toggle, hide })
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
running: 'running',
moreOptions: 'More options',
clearHistory: 'Clear history'
}
}
}
}
})
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
headerTitle: 'Job queue',
showConcurrentIndicator: true,
concurrentWorkflowCount: 2,
...props
},
global: {
plugins: [i18n],
directives: { tooltip: tooltipDirectiveStub }
}
})
describe('QueueOverlayHeader', () => {
it('renders header title and concurrent indicator when enabled', () => {
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
expect(wrapper.text()).toContain('Job queue')
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
expect(indicator.exists()).toBe(true)
expect(indicator.text()).toContain('3')
expect(indicator.text()).toContain('running')
})
it('hides concurrent indicator when flag is false', () => {
const wrapper = mountHeader({ showConcurrentIndicator: false })
expect(wrapper.text()).toContain('Job queue')
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('toggles popover and emits clear history', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
const moreButton = wrapper.get('button[aria-label="More options"]')
await moreButton.trigger('click')
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
await clearHistoryButton.trigger('click')
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,96 @@
<template>
<div
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
>
<div class="px-2 text-[14px] font-normal text-text-primary">
<span>{{ headerTitle }}</span>
<span
v-if="showConcurrentIndicator"
class="ml-4 inline-flex items-center gap-1 text-blue-100"
>
<span class="inline-block size-2 rounded-full bg-blue-100" />
<span>
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
</span>
</div>
<div class="flex items-center gap-1">
<button
v-tooltip.top="moreTooltipConfig"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</button>
<Popover
ref="morePopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</button>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
}>()
const emit = defineEmits<{
(e: 'clearHistory'): void
}>()
const { t } = useI18n()
const morePopoverRef = ref<PopoverMethods | null>(null)
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')
}
</script>

View File

@@ -0,0 +1,290 @@
<template>
<div
v-show="isVisible"
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
>
<div
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
:class="containerClass"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<!-- Expanded state -->
<QueueOverlayExpanded
v-if="isExpanded"
v-model:selected-job-tab="selectedJobTab"
v-model:selected-workflow-filter="selectedWorkflowFilter"
v-model:selected-sort-mode="selectedSortMode"
class="flex-1 min-h-0"
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="openAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="inspectJobAsset"
/>
<QueueOverlayActive
v-else-if="hasActiveJob"
:total-progress-style="totalProgressStyle"
:current-node-progress-style="currentNodeProgressStyle"
:total-percent-formatted="totalPercentFormatted"
:current-node-percent-formatted="currentNodePercentFormatted"
:current-node-name="currentNodeName"
:running-count="runningCount"
:queued-count="queuedCount"
:bottom-row-class="bottomRowClass"
@interrupt-all="interruptAll"
@clear-queued="cancelQueuedWorkflows"
@view-all-jobs="viewAllJobs"
/>
<QueueOverlayEmpty
v-else-if="completionSummary"
:summary="completionSummary"
@summary-click="onSummaryClick"
/>
</div>
</div>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
const props = defineProps<{
expanded?: boolean
}>()
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
}>()
const { t } = useI18n()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const dialogStore = useDialogStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
totalPercentFormatted,
currentNodePercentFormatted,
totalProgressStyle,
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () =>
props.expanded === undefined ? internalExpanded.value : props.expanded,
set: (value) => {
if (props.expanded === undefined) {
internalExpanded.value = value
}
emit('update:expanded', value)
}
})
const { summary: completionSummary, clearSummary } = useCompletionSummary()
const hasCompletionSummary = computed(() => completionSummary.value !== null)
const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isExecuting = computed(() => !executionStore.isIdle)
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
const overlayState = computed<OverlayState>(() => {
if (isExpanded.value) return 'expanded'
if (hasActiveJob.value) return 'active'
if (hasCompletionSummary.value) return 'empty'
return 'hidden'
})
const showBackground = computed(
() =>
overlayState.value === 'expanded' ||
overlayState.value === 'empty' ||
(overlayState.value === 'active' && isHovered.value)
)
const isVisible = computed(() => overlayState.value !== 'hidden')
const containerClass = computed(() =>
showBackground.value
? 'border-interface-stroke bg-interface-panel-surface shadow-interface'
: 'border-transparent bg-transparent shadow-none'
)
const bottomRowClass = computed(
() =>
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
overlayState.value === 'active' && isHovered.value
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
}`
)
const headerTitle = computed(() =>
hasActiveJob.value
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
: t('sideToolbar.queueProgressOverlay.jobQueue')
)
const concurrentWorkflowCount = computed(
() => executionStore.runningWorkflowCount
)
const showConcurrentIndicator = computed(
() => concurrentWorkflowCount.value > 1
)
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
filteredTasks,
groupedJobItems,
currentNodeName
} = useJobList()
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const promptId = item.taskRef?.promptId
if (!promptId) return
await api.interrupt(promptId)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
const {
galleryActiveIndex,
galleryItems,
onViewItem: openResultGallery
} = useResultGallery(() => filteredTasks.value)
const setExpanded = (expanded: boolean) => {
isExpanded.value = expanded
}
const openExpandedFromEmpty = () => {
setExpanded(true)
}
const viewAllJobs = () => {
setExpanded(true)
}
const onSummaryClick = () => {
openExpandedFromEmpty()
clearSummary()
}
const openAssetsSidebar = () => {
sidebarTabStore.activeSidebarTabId = 'assets'
}
const focusAssetInSidebar = async (item: JobListItem) => {
const task = item.taskRef
const promptId = task?.promptId
const preview = task?.previewOutput
if (!promptId || !preview) return
const assetId = String(promptId)
openAssetsSidebar()
await nextTick()
await assetsStore.updateHistory()
const asset = assetsStore.historyAssets.find(
(existingAsset) => existingAsset.id === assetId
)
if (!asset) {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
}
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
await Promise.all(
tasks
.filter((task) => task.promptId != null)
.map((task) => api.interrupt(task.promptId))
)
})
const showClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
},
content: {
class: '!p-0 bg-transparent'
}
}
}
})
}
const onClearHistoryFromMenu = () => {
showClearHistoryDialog()
}
</script>

View File

@@ -0,0 +1,88 @@
<template>
<section
class="w-[360px] rounded-2xl border border-interface-stroke bg-interface-panel-surface text-text-primary shadow-interface font-inter"
>
<header
class="flex items-center justify-between border-b border-interface-stroke px-4 py-4"
>
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p>
<button
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</button>
</header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
<p class="m-0">
{{
t('sideToolbar.queueProgressOverlay.clearHistoryDialogDescription')
}}
</p>
<p class="m-0">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogAssetsNote') }}
</p>
</div>
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<button
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
:aria-label="t('g.cancel')"
@click="onCancel"
>
{{ t('g.cancel') }}
</button>
<button
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
:aria-label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
>
{{ t('g.clear') }}
</button>
</div>
</footer>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'
const dialogStore = useDialogStore()
const queueStore = useQueueStore()
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isClearing = ref(false)
const clearHistory = wrapWithErrorHandlingAsync(
async () => {
await queueStore.clear(['history'])
dialogStore.closeDialog()
},
undefined,
() => {
isClearing.value = false
}
)
const onConfirm = async () => {
if (isClearing.value) return
isClearing.value = true
await clearHistory()
}
const onCancel = () => {
dialogStore.closeDialog()
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<Popover
ref="jobItemPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[14rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<button
v-else
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
:aria-label="entry.label"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</button>
</template>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref } from 'vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
const jobItemPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
function open(event: Event) {
if (jobItemPopoverRef.value) {
jobItemPopoverRef.value.toggle(event)
}
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onEntry(entry: MenuEntry) {
emit('action', entry)
}
defineExpose({ open, hide })
</script>

View File

@@ -0,0 +1,423 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TaskStatus } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import JobDetailsPopover from './JobDetailsPopover.vue'
const meta: Meta<typeof JobDetailsPopover> = {
title: 'Queue/JobDetailsPopover',
component: JobDetailsPopover,
args: {
workflowId: 'WF-1234'
},
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark'
}
},
globals: {
theme: 'dark'
}
}
export default meta
type Story = StoryObj<typeof meta>
function resetStores() {
const queue = useQueueStore()
const exec = useExecutionStore()
queue.pendingTasks = []
queue.runningTasks = []
queue.historyTasks = []
exec.nodeProgressStatesByPrompt = {}
}
function makePendingTask(
id: string,
index: number,
createTimeMs?: number
): TaskItemImpl {
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Pending', [index, id, {}, extraData, []])
}
function makeRunningTask(
id: string,
index: number,
createTimeMs?: number
): TaskItemImpl {
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Running', [index, id, {}, extraData, []])
}
function makeRunningTaskWithStart(
id: string,
index: number,
startedSecondsAgo: number
): TaskItemImpl {
const start = Date.now() - startedSecondsAgo * 1000
const status: TaskStatus = {
status_str: 'success',
completed: false,
messages: [['execution_start', { prompt_id: id, timestamp: start } as any]]
}
return new TaskItemImpl(
'Running',
[index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []],
status
)
}
function makeHistoryTask(
id: string,
index: number,
durationSec: number,
ok: boolean,
errorMessage?: string
): TaskItemImpl {
const start = Date.now() - durationSec * 1000 - 1000
const end = start + durationSec * 1000
const messages: TaskStatus['messages'] = ok
? [
['execution_start', { prompt_id: id, timestamp: start } as any],
['execution_success', { prompt_id: id, timestamp: end } as any]
]
: [
['execution_start', { prompt_id: id, timestamp: start } as any],
[
'execution_error',
{
prompt_id: id,
timestamp: end,
node_id: '1',
node_type: 'Node',
executed: [],
exception_message:
errorMessage || 'Demo error: Node failed during execution',
exception_type: 'RuntimeError',
traceback: [],
current_inputs: {},
current_outputs: {}
} as any
]
]
const status: TaskStatus = {
status_str: ok ? 'success' : 'error',
completed: true,
messages
}
return new TaskItemImpl(
'History',
[index, id, {}, { client_id: 'c1', create_time: start }, []],
status
)
}
export const Queued: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const queueIndex = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(makePendingTask('job-older-1', 100))
queue.pendingTasks.push(makePendingTask('job-older-2', 101))
// Queued at (in metadata on prompt[4])
// One running workflow
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
max: 1,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const QueuedParallel: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const queueIndex = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
// History durations for ETA (in seconds)
queue.historyTasks = [
makeHistoryTask('hist-1', 150, 25, true),
makeHistoryTask('hist-2', 151, 40, true),
makeHistoryTask('hist-3', 152, 60, true)
]
// Two parallel workflows running
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
max: 2,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
},
p2: {
'2': {
value: 1,
max: 2,
state: 'running',
node_id: '2',
prompt_id: 'p2'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const Running: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-running-1'
const queueIndex = 300
queue.runningTasks = [
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
makeHistoryTask('hist-r2', 251, 45, true),
makeHistoryTask('hist-r3', 252, 60, true)
]
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const QueuedZeroAheadSingleRunning: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const queueIndex = 510
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
]
queue.historyTasks = [
makeHistoryTask('hist-s1', 480, 30, true),
makeHistoryTask('hist-s2', 481, 50, true),
makeHistoryTask('hist-s3', 482, 80, true)
]
queue.runningTasks = [makeRunningTaskWithStart('running-1', 505, 20)]
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
max: 3,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const QueuedZeroAheadMultiRunning: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const queueIndex = 520
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
]
queue.historyTasks = [
makeHistoryTask('hist-m1', 490, 40, true),
makeHistoryTask('hist-m2', 491, 55, true),
makeHistoryTask('hist-m3', 492, 70, true)
]
queue.runningTasks = [
makeRunningTaskWithStart('running-a', 506, 35),
makeRunningTaskWithStart('running-b', 507, 10)
]
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 2,
max: 5,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
},
p2: {
'2': {
value: 3,
max: 5,
state: 'running',
node_id: '2',
prompt_id: 'p2'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const Completed: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const jobId = 'job-completed-1'
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const Failed: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const jobId = 'job-failed-1'
const queueIndex = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
queueIndex,
12,
false,
'Example error: invalid inputs for node X'
)
]
// Show a queued-at time for the failed job via history extra_data (2 minutes ago)
// Already set by makeHistoryTask using its start timestamp
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}

View File

@@ -0,0 +1,350 @@
<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>
<button
v-if="row.canCopy"
type="button"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyJobId"
>
<i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/>
</button>
</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">
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyErrorMessage"
>
<span>{{ copyAriaLabel }}</span>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button>
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
@click.stop="reportJobError"
>
<span>{{ t('queue.jobDetails.report') }}</span>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</button>
</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 { 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>

View File

@@ -0,0 +1,224 @@
<template>
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<button
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:class="[
selectedJobTab === tab
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
]"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<button
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
<i
class="icon-[lucide--list-filter] block size-4 leading-none text-text-primary"
/>
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</button>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
<div class="mx-2 mt-1 h-px" />
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</div>
</Popover>
<button
v-tooltip.top="sortTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
<i
class="icon-[lucide--arrow-up-down] block size-4 leading-none text-text-primary"
/>
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
const props = defineProps<{
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
hasFailedJobs: boolean
}>()
const emit = defineEmits<{
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
}>()
const { t } = useI18n()
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const sortPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
// This can be removed when cloud implements /jobs and we switch to it.
const showWorkflowFilter = !isCloud
const visibleJobTabs = computed(() =>
props.hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const onFilterClick = (event: Event) => {
if (filterPopoverRef.value) {
filterPopoverRef.value.toggle(event)
}
}
const selectWorkflowFilter = (value: 'all' | 'current') => {
;(filterPopoverRef.value as any)?.hide?.()
emit('update:selectedWorkflowFilter', value)
}
const onSortClick = (event: Event) => {
if (sortPopoverRef.value) {
sortPopoverRef.value.toggle(event)
}
}
const selectSortMode = (value: JobSortMode) => {
;(sortPopoverRef.value as any)?.hide?.()
emit('update:selectedSortMode', value)
}
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-[12px] leading-none text-text-secondary">
{{ group.label }}
</div>
<QueueJobItem
v-for="ji in group.items"
:key="ji.id"
:job-id="ji.id"
:workflow-id="ji.taskRef?.workflow?.id"
:state="ji.state"
:title="ji.title"
:right-text="ji.meta"
:icon-name="ji.iconName"
:icon-image-url="ji.iconImageUrl"
:show-clear="ji.showClear"
:show-menu="true"
:progress-total-percent="ji.progressTotalPercent"
:progress-current-percent="ji.progressCurrentPercent"
:running-node-name="ji.runningNodeName"
:active-details-id="activeDetailsId"
@cancel="emitCancelItem(ji)"
@delete="emitDeleteItem(ji)"
@menu="(ev) => $emit('menu', ji, ev)"
@view="$emit('viewItem', ji)"
@details-enter="onDetailsEnter"
@details-leave="onDetailsLeave"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const emitCancelItem = (item: JobListItem) => {
emit('cancelItem', item)
}
const emitDeleteItem = (item: JobListItem) => {
emit('deleteItem', item)
}
const activeDetailsId = ref<string | null>(null)
const hideTimer = ref<number | null>(null)
const showTimer = ref<number | null>(null)
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const onDetailsEnter = (jobId: string) => {
clearHideTimer()
clearShowTimer()
showTimer.value = window.setTimeout(() => {
activeDetailsId.value = jobId
showTimer.value = null
}, 200)
}
const onDetailsLeave = (jobId: string) => {
clearHideTimer()
clearShowTimer()
hideTimer.value = window.setTimeout(() => {
if (activeDetailsId.value === jobId) activeDetailsId.value = null
hideTimer.value = null
}, 150)
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
<div class="p-3">
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
<img
ref="imgRef"
:src="imageUrl"
:alt="name"
class="h-full w-full cursor-pointer object-contain"
@click="$emit('image-click')"
@load="onImgLoad"
/>
<div
v-if="timeLabel"
class="absolute bottom-2 left-2 rounded px-2 py-0.5 text-xs text-text-primary"
:style="{
background: 'rgba(217, 217, 217, 0.40)',
backdropFilter: 'blur(2px)'
}"
>
{{ timeLabel }}
</div>
</div>
<div class="mt-2 text-center">
<div
class="truncate text-[0.875rem] leading-normal font-semibold text-text-primary"
:title="name"
>
{{ name }}
</div>
<div
v-if="width && height"
class="mt-1 text-[0.75rem] leading-normal text-text-secondary"
>
{{ width }}x{{ height }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps<{
imageUrl: string
name: string
timeLabel?: string
}>()
defineEmits(['image-click'])
const imgRef = ref<HTMLImageElement | null>(null)
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const onImgLoad = () => {
const el = imgRef.value
if (!el) return
width.value = el.naturalWidth || null
height.value = el.naturalHeight || null
}
</script>

View File

@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QueueJobItem from './QueueJobItem.vue'
const meta: Meta<typeof QueueJobItem> = {
title: 'Queue/QueueJobItem',
component: QueueJobItem,
parameters: {
layout: 'padded'
},
argTypes: {
onCancel: { action: 'cancel' },
onDelete: { action: 'delete' },
onMenu: { action: 'menu' },
onView: { action: 'view' }
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
export const PendingRecentlyAdded: Story = {
args: {
jobId: 'job-pending-added-1',
state: 'pending',
title: 'Job added to queue',
rightText: '12:30 PM',
iconName: 'icon-[lucide--check]'
}
}
export const Pending: Story = {
args: {
jobId: 'job-pending-1',
state: 'pending',
title: 'Pending job',
rightText: '12:31 PM'
}
}
export const Initialization: Story = {
args: {
jobId: 'job-init-1',
state: 'initialization',
title: 'Initializing...'
}
}
export const RunningTotalOnly: Story = {
args: {
jobId: 'job-running-1',
state: 'running',
title: 'Generating image',
progressTotalPercent: 42
}
}
export const RunningWithCurrent: Story = {
args: {
jobId: 'job-running-2',
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10,
runningNodeName: 'KSampler'
}
}
export const CompletedWithPreview: Story = {
args: {
jobId: 'job-completed-1',
state: 'completed',
title: 'Prompt #1234',
rightText: '12.79s',
iconImageUrl: thumb('4dabf7')
}
}
export const CompletedNoPreview: Story = {
args: {
jobId: 'job-completed-2',
state: 'completed',
title: 'Prompt #5678',
rightText: '8.12s'
}
}
export const Failed: Story = {
args: {
jobId: 'job-failed-1',
state: 'failed',
title: 'Failed job',
rightText: 'Failed'
}
}
export const Gallery: Story = {
render: (args) => ({
components: { QueueJobItem },
setup() {
return { args }
},
template: `
<div class="flex flex-col gap-2 w-[420px]">
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
<QueueJobItem
job-id="job-running-2"
state="running"
title="Generating image"
:progress-total-percent="66"
:progress-current-percent="10"
running-node-name="KSampler"
v-bind="args"
/>
<QueueJobItem
job-id="job-completed-1"
state="completed"
title="Prompt #1234"
right-text="12.79s"
icon-image-url="${thumb('4dabf7')}"
v-bind="args"
/>
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
</div>
`
})
}

View File

@@ -0,0 +1,310 @@
<template>
<div
ref="rowRef"
class="relative"
@mouseenter="onRowEnter"
@mouseleave="onRowLeave"
@contextmenu.stop.prevent="onContextMenu"
>
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
right: `${popoverPosition.right}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover
:job-id="props.jobId"
:workflow-id="props.workflowId"
/>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
right: `${popoverPosition.right}px`
}"
@mouseenter="onPreviewEnter"
@mouseleave="onPreviewLeave"
>
<QueueAssetPreview
:image-url="iconImageUrl!"
:name="props.title"
:time-label="rightText || undefined"
@image-click="emit('view')"
/>
</div>
</Teleport>
<div
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
class="absolute inset-0"
>
<div
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
/>
<div
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i v-else :class="[iconClass, 'size-4']" />
</div>
</div>
</div>
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
enter-from-class="opacity-0 translate-y-0.5"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-0.5"
>
<div
v-if="isHovered"
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<button
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</button>
<button
v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
>
<i class="icon-[lucide--x] size-4" />
</button>
<button
v-else-if="props.state === 'completed'"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('menuLabels.View')"
@click.stop="emit('view')"
>
<span>{{ t('menuLabels.View') }}</span>
</button>
<button
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</button>
</div>
<div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</Transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
const props = withDefaults(
defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
activeDetailsId?: string | null
}>(),
{
workflowId: undefined,
rightText: '',
iconName: undefined,
iconImageUrl: undefined,
showClear: undefined,
showMenu: undefined,
progressTotalPercent: undefined,
progressCurrentPercent: undefined,
runningNodeName: undefined,
activeDetailsId: null
}
)
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'delete'): void
(e: 'menu', event: MouseEvent): void
(e: 'view'): void
(e: 'details-enter', jobId: string): void
(e: 'details-leave', jobId: string): void
}>()
const { t } = useI18n()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => props.activeDetailsId === props.jobId)
const onRowEnter = () => {
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
}
const onRowLeave = () => emit('details-leave', props.jobId)
const onPopoverEnter = () => emit('details-enter', props.jobId)
const onPopoverLeave = () => emit('details-leave', props.jobId)
const isPreviewVisible = ref(false)
const previewHideTimer = ref<number | null>(null)
const previewShowTimer = ref<number | null>(null)
const clearPreviewHideTimer = () => {
if (previewHideTimer.value !== null) {
clearTimeout(previewHideTimer.value)
previewHideTimer.value = null
}
}
const clearPreviewShowTimer = () => {
if (previewShowTimer.value !== null) {
clearTimeout(previewShowTimer.value)
previewShowTimer.value = null
}
}
const canShowPreview = computed(
() => props.state === 'completed' && !!props.iconImageUrl
)
const scheduleShowPreview = () => {
if (!canShowPreview.value) return
clearPreviewHideTimer()
clearPreviewShowTimer()
previewShowTimer.value = window.setTimeout(() => {
isPreviewVisible.value = true
previewShowTimer.value = null
}, 200)
}
const scheduleHidePreview = () => {
clearPreviewHideTimer()
clearPreviewShowTimer()
previewHideTimer.value = window.setTimeout(() => {
isPreviewVisible.value = false
previewHideTimer.value = null
}, 150)
}
const onIconEnter = () => scheduleShowPreview()
const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const updatePopoverPosition = () => {
const el = rowRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const gap = 8
popoverPosition.value = {
top: rect.top,
right: window.innerWidth - rect.left + gap
}
}
const isAnyPopoverVisible = computed(
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
)
watch(
isAnyPopoverVisible,
(visible) => {
if (visible) {
nextTick(updatePopoverPosition)
} else {
popoverPosition.value = null
}
},
{ immediate: false }
)
const isHovered = ref(false)
const iconClass = computed(() => {
if (props.iconName) return props.iconName
return iconForJobState(props.state)
})
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)
}
</script>

View File

@@ -0,0 +1,83 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
type CopyHandler = (value: string) => void | Promise<void>
export type JobErrorDialogService = {
showExecutionErrorDialog: (error: ExecutionErrorWsMessage) => void
showErrorDialog: (
error: Error,
options?: {
reportType?: string
[key: string]: unknown
}
) => void
}
type JobExecutionError = {
detail?: ExecutionErrorWsMessage
message: string
}
export const extractExecutionError = (
task: TaskItemImpl | null
): JobExecutionError | null => {
const status = (task as TaskItemImpl | null)?.status
const messages = (status as { messages?: unknown[] } | undefined)?.messages
if (!Array.isArray(messages) || !messages.length) return null
const record = messages.find((entry: unknown) => {
return Array.isArray(entry) && entry[0] === 'execution_error'
}) as [string, ExecutionErrorWsMessage?] | undefined
if (!record) return null
const detail = record[1]
const message = String(detail?.exception_message ?? '')
return {
detail,
message
}
}
type UseJobErrorReportingOptions = {
taskForJob: ComputedRef<TaskItemImpl | null>
copyToClipboard: CopyHandler
dialog: JobErrorDialogService
}
export const useJobErrorReporting = ({
taskForJob,
copyToClipboard,
dialog
}: UseJobErrorReportingOptions) => {
const errorMessageValue = computed(() => {
const error = extractExecutionError(taskForJob.value)
return error?.message ?? ''
})
const copyErrorMessage = () => {
if (errorMessageValue.value) {
void copyToClipboard(errorMessageValue.value)
}
}
const reportJobError = () => {
const error = extractExecutionError(taskForJob.value)
if (error?.detail) {
dialog.showExecutionErrorDialog(error.detail)
return
}
if (errorMessageValue.value) {
dialog.showErrorDialog(new Error(errorMessageValue.value), {
reportType: 'queueJobError'
})
}
}
return {
errorMessageValue,
copyErrorMessage,
reportJobError
}
}

View File

@@ -0,0 +1,230 @@
import { computed, ref } from 'vue'
import { describe, expect, it } from 'vitest'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
import type { UseQueueEstimatesOptions } from './useQueueEstimates'
type QueueStore = UseQueueEstimatesOptions['queueStore']
type ExecutionStore = UseQueueEstimatesOptions['executionStore']
const makeHistoryTask = (
executionTimeInSeconds: number | string | undefined
): TaskItemImpl =>
({
executionTimeInSeconds
}) as TaskItemImpl
const makeRunningTask = (executionStartTimestamp?: number): TaskItemImpl =>
({
executionStartTimestamp
}) as TaskItemImpl
const createQueueStore = (data?: Partial<QueueStore>): QueueStore =>
({
historyTasks: [],
runningTasks: [],
...data
}) as QueueStore
const createExecutionStore = (data?: Partial<ExecutionStore>): ExecutionStore =>
({
runningWorkflowCount: 1,
...data
}) as ExecutionStore
type HarnessOptions = {
queueStore?: QueueStore
executionStore?: ExecutionStore
task?: TaskItemImpl | null
jobState?: JobState | null
firstSeenTs?: number
jobsAhead?: number | null
now?: number
}
const createHarness = (options?: HarnessOptions) => {
const queueStore = options?.queueStore ?? createQueueStore()
const executionStore = options?.executionStore ?? createExecutionStore()
const taskRef = ref<TaskItemImpl | null>(options?.task ?? null)
const jobStateRef = ref<JobState | null>(options?.jobState ?? null)
const firstSeenRef = ref<number | undefined>(options?.firstSeenTs)
const jobsAheadRef = ref<number | null>(options?.jobsAhead ?? null)
const nowRef = ref(options?.now ?? 0)
const result = useQueueEstimates({
queueStore,
executionStore,
taskForJob: computed(() => taskRef.value),
jobState: computed(() => jobStateRef.value),
firstSeenTs: computed(() => firstSeenRef.value),
jobsAhead: computed(() => jobsAheadRef.value),
nowTs: nowRef
})
return {
...result,
queueStore,
executionStore,
taskRef,
jobStateRef,
firstSeenRef,
jobsAheadRef,
nowRef
}
}
describe('formatElapsedTime', () => {
it('formats elapsed milliseconds and clamps negatives to zero', () => {
expect(formatElapsedTime(0)).toBe('0m 0s')
expect(formatElapsedTime(61000)).toBe('1m 1s')
expect(formatElapsedTime(90000)).toBe('1m 30s')
expect(formatElapsedTime(-5000)).toBe('0m 0s')
})
})
describe('useQueueEstimates', () => {
it('only shows parallel queued stats for pending jobs seen with multiple runners', () => {
const ready = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
jobState: 'pending',
firstSeenTs: 1000
})
expect(ready.showParallelQueuedStats.value).toBe(true)
const missingTimestamp = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
jobState: 'pending'
})
expect(missingTimestamp.showParallelQueuedStats.value).toBe(false)
const singleRunner = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 1 }),
jobState: 'pending',
firstSeenTs: 1000
})
expect(singleRunner.showParallelQueuedStats.value).toBe(false)
const runningJob = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 3 }),
jobState: 'running',
firstSeenTs: 1000
})
expect(runningJob.showParallelQueuedStats.value).toBe(false)
})
it('uses the last 20 valid durations to estimate queued batches', () => {
const durations = Array.from({ length: 25 }, (_, idx) => idx + 1)
const queueStore = createQueueStore({
historyTasks: [
...durations.slice(0, 5).map((value) => makeHistoryTask(value)),
makeHistoryTask('not-a-number'),
makeHistoryTask(undefined),
...durations.slice(5).map((value) => makeHistoryTask(value))
]
})
const { estimateRangeSeconds } = createHarness({
queueStore,
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
jobsAhead: 5
})
expect(estimateRangeSeconds.value).toEqual([47, 63])
})
it('returns null for estimateRangeSeconds when no history or jobsAhead is unknown', () => {
const emptyHistory = createHarness({
queueStore: createQueueStore(),
jobsAhead: 2
})
expect(emptyHistory.estimateRangeSeconds.value).toBeNull()
const missingAhead = createHarness({
queueStore: createQueueStore({
historyTasks: [makeHistoryTask(10)]
})
})
expect(missingAhead.estimateRangeSeconds.value).toBeNull()
})
it('falls back to the running remaining range when there are no jobs ahead', () => {
const now = 20000
const queueStore = createQueueStore({
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value)),
runningTasks: [
makeRunningTask(now - 5000),
makeRunningTask(now - 15000),
makeRunningTask(undefined)
]
})
const { estimateRangeSeconds } = createHarness({
queueStore,
jobsAhead: 0,
now
})
expect(estimateRangeSeconds.value).toEqual([5, 15])
})
it('subtracts elapsed time when estimating a running job', () => {
const now = 25000
const queueStore = createQueueStore({
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value))
})
const { estimateRemainingRangeSeconds } = createHarness({
queueStore,
task: makeRunningTask(5000),
jobState: 'running',
firstSeenTs: 2000,
now
})
expect(estimateRemainingRangeSeconds.value).toEqual([0, 10])
})
it('uses the first-seen timestamp for pending jobs and clamps negatives to zero', () => {
const queueStore = createQueueStore({
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value))
})
const harness = createHarness({
queueStore,
jobState: 'pending',
firstSeenTs: 10000,
now: 25000
})
expect(harness.estimateRemainingRangeSeconds.value).toEqual([5, 15])
harness.firstSeenRef.value = 1000
harness.nowRef.value = 70000
expect(harness.estimateRemainingRangeSeconds.value).toEqual([0, 0])
})
it('computes the elapsed label using execution start, then first-seen timestamp', () => {
const harness = createHarness()
harness.taskRef.value = makeRunningTask(1000)
harness.jobStateRef.value = 'running'
harness.nowRef.value = 4000
expect(harness.timeElapsedValue.value).toBe('0m 3s')
harness.jobStateRef.value = 'pending'
harness.firstSeenRef.value = 2000
harness.nowRef.value = 5000
expect(harness.timeElapsedValue.value).toBe('0m 3s')
harness.taskRef.value = null
harness.firstSeenRef.value = undefined
expect(harness.timeElapsedValue.value).toBe('')
})
})

View File

@@ -0,0 +1,149 @@
import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { useExecutionStore } from '@/stores/executionStore'
import type { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
type QueueStore = ReturnType<typeof useQueueStore>
type ExecutionStore = ReturnType<typeof useExecutionStore>
export type UseQueueEstimatesOptions = {
queueStore: QueueStore
executionStore: ExecutionStore
taskForJob: ComputedRef<TaskItemImpl | null>
jobState: ComputedRef<JobState | null>
firstSeenTs: ComputedRef<number | undefined>
jobsAhead: ComputedRef<number | null>
nowTs: Ref<number>
}
type EstimateRange = [number, number]
export const formatElapsedTime = (ms: number): string => {
const totalSec = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(totalSec / 60)
const seconds = totalSec % 60
return `${minutes}m ${seconds}s`
}
const pickRecentDurations = (queueStore: QueueStore) =>
queueStore.historyTasks
.map((task: TaskItemImpl) => Number(task.executionTimeInSeconds))
.filter(
(value: number | undefined) =>
typeof value === 'number' && !Number.isNaN(value)
) as number[]
export const useQueueEstimates = ({
queueStore,
executionStore,
taskForJob,
jobState,
firstSeenTs,
jobsAhead,
nowTs
}: UseQueueEstimatesOptions) => {
const runningWorkflowCount = computed(
() => executionStore.runningWorkflowCount
)
const showParallelQueuedStats = computed(
() =>
jobState.value === 'pending' &&
!!firstSeenTs.value &&
(runningWorkflowCount.value ?? 0) > 1
)
const recentDurations = computed<number[]>(() =>
pickRecentDurations(queueStore).slice(-20)
)
const runningRemainingRangeSeconds = computed<EstimateRange | null>(() => {
const durations = recentDurations.value
if (!durations.length) return null
const sorted = durations.slice().sort((a, b) => a - b)
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
const p75 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
const running = queueStore.runningTasks as TaskItemImpl[]
const now = nowTs.value
const remaining = running
.map((task) => task.executionStartTimestamp)
.filter((timestamp): timestamp is number => typeof timestamp === 'number')
.map((startTs) => {
const elapsed = Math.max(0, Math.floor((now - startTs) / 1000))
return {
lo: Math.max(0, Math.round(avg - elapsed)),
hi: Math.max(0, Math.round(p75 - elapsed))
}
})
if (!remaining.length) return null
const minLo = remaining.reduce(
(min, range) => Math.min(min, range.lo),
Infinity
)
const minHi = remaining.reduce(
(min, range) => Math.min(min, range.hi),
Infinity
)
return [minLo, minHi]
})
const estimateRangeSeconds = computed<EstimateRange | null>(() => {
const durations = recentDurations.value
if (!durations.length) return null
const ahead = jobsAhead.value
if (ahead == null) return null
const sorted = durations.slice().sort((a, b) => a - b)
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
const p75 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
if (ahead <= 0) {
return runningRemainingRangeSeconds.value ?? [0, 0]
}
const runningCount = Math.max(1, runningWorkflowCount.value || 1)
const batches = Math.ceil(ahead / runningCount)
return [Math.round(avg * batches), Math.round(p75 * batches)]
})
const estimateRemainingRangeSeconds = computed<EstimateRange | null>(() => {
const durations = recentDurations.value
if (!durations.length) return null
const sorted = durations.slice().sort((a, b) => a - b)
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
const p75 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
const task = taskForJob.value as TaskItemImpl & {
executionStartTimestamp?: number
}
const execStart =
jobState.value === 'running' ? task?.executionStartTimestamp : undefined
const baseTs = execStart ?? firstSeenTs.value
const elapsed = baseTs
? Math.max(0, Math.floor((nowTs.value - baseTs) / 1000))
: 0
const lo = Math.max(0, Math.round(avg - elapsed))
const hi = Math.max(0, Math.round(p75 - elapsed))
return [lo, hi]
})
const timeElapsedValue = computed(() => {
const task = taskForJob.value as TaskItemImpl & {
executionStartTimestamp?: number
}
const execStart =
jobState.value === 'running' ? task?.executionStartTimestamp : undefined
const baseTs = execStart ?? firstSeenTs.value
if (!baseTs) return ''
return formatElapsedTime(nowTs.value - baseTs)
})
return {
runningWorkflowCount,
showParallelQueuedStats,
estimateRangeSeconds,
estimateRemainingRangeSeconds,
timeElapsedValue
}
}