mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-24 00:34:09 +00:00
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:
352
src/composables/queue/useJobList.ts
Normal file
352
src/composables/queue/useJobList.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import {
|
||||
dateKey,
|
||||
formatClockTime,
|
||||
formatShortMonthDay,
|
||||
isToday,
|
||||
isYesterday
|
||||
} from '@/utils/dateTimeUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
|
||||
/** Tabs for job list filtering */
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
export type JobTab = (typeof jobTabs)[number]
|
||||
|
||||
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
|
||||
export type JobSortMode = (typeof jobSortModes)[number]
|
||||
|
||||
/**
|
||||
* UI item in the job list. Mirrors data previously prepared inline.
|
||||
*/
|
||||
export type JobListItem = {
|
||||
id: string
|
||||
title: string
|
||||
meta: string
|
||||
state: JobState
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
taskRef?: any
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
runningNodeName?: string
|
||||
executionTimeMs?: number
|
||||
computeHours?: number
|
||||
}
|
||||
|
||||
export type JobGroup = {
|
||||
key: string
|
||||
label: string
|
||||
items: JobListItem[]
|
||||
}
|
||||
|
||||
const ADDED_HINT_DURATION_MS = 3000
|
||||
const relativeTimeFormatterCache = new Map<string, Intl.RelativeTimeFormat>()
|
||||
const taskIdToKey = (id: string | number | undefined) => {
|
||||
if (id === null || id === undefined) return null
|
||||
const key = String(id)
|
||||
return key.length ? key : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns localized Today/Yesterday (capitalized) or localized Mon DD.
|
||||
*/
|
||||
const dateLabelForTimestamp = (
|
||||
ts: number,
|
||||
locale: string,
|
||||
relativeFormatter: Intl.RelativeTimeFormat
|
||||
) => {
|
||||
const formatRelativeDay = (value: number) => {
|
||||
const formatted = relativeFormatter.format(value, 'day')
|
||||
return formatted
|
||||
? formatted[0].toLocaleUpperCase(locale) + formatted.slice(1)
|
||||
: formatted
|
||||
}
|
||||
if (isToday(ts)) {
|
||||
return formatRelativeDay(0)
|
||||
}
|
||||
if (isYesterday(ts)) {
|
||||
return formatRelativeDay(-1)
|
||||
}
|
||||
return formatShortMonthDay(ts, locale)
|
||||
}
|
||||
|
||||
type TaskWithState = {
|
||||
task: TaskItemImpl
|
||||
state: JobState
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the reactive job list, filters, and grouped view for the queue overlay.
|
||||
*/
|
||||
export function useJobList() {
|
||||
const { t, locale } = useI18n()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const clearAddedHintTimeout = (id: string) => {
|
||||
const timeoutId = addedHintTimeouts.get(id)
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
addedHintTimeouts.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleAddedHintExpiry = (id: string) => {
|
||||
clearAddedHintTimeout(id)
|
||||
const timeoutId = setTimeout(() => {
|
||||
addedHintTimeouts.delete(id)
|
||||
const updated = new Set(recentlyAddedPendingIds.value)
|
||||
if (updated.delete(id)) {
|
||||
recentlyAddedPendingIds.value = updated
|
||||
}
|
||||
}, ADDED_HINT_DURATION_MS)
|
||||
addedHintTimeouts.set(id, timeoutId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
queueStore.pendingTasks
|
||||
.map((task) => taskIdToKey(task.promptId))
|
||||
.filter((id): id is string => !!id),
|
||||
(pendingIds) => {
|
||||
const pendingSet = new Set(pendingIds)
|
||||
const next = new Set(recentlyAddedPendingIds.value)
|
||||
|
||||
pendingIds.forEach((id) => {
|
||||
if (!next.has(id)) {
|
||||
next.add(id)
|
||||
scheduleAddedHintExpiry(id)
|
||||
}
|
||||
})
|
||||
|
||||
for (const id of Array.from(next)) {
|
||||
if (!pendingSet.has(id)) {
|
||||
next.delete(id)
|
||||
clearAddedHintTimeout(id)
|
||||
}
|
||||
}
|
||||
|
||||
recentlyAddedPendingIds.value = next
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
|
||||
if (state !== 'pending') return false
|
||||
const id = taskIdToKey(task.promptId)
|
||||
if (!id) return false
|
||||
return recentlyAddedPendingIds.value.has(id)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
||||
addedHintTimeouts.clear()
|
||||
recentlyAddedPendingIds.value = new Set<string>()
|
||||
})
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
|
||||
const relativeTimeFormatter = computed(() => {
|
||||
const localeValue = locale.value
|
||||
let formatter = relativeTimeFormatterCache.get(localeValue)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.RelativeTimeFormat(localeValue, { numeric: 'auto' })
|
||||
relativeTimeFormatterCache.set(localeValue, formatter)
|
||||
}
|
||||
return formatter
|
||||
})
|
||||
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
|
||||
|
||||
const isJobInitializing = (promptId: string | number | undefined) =>
|
||||
executionStore.isPromptInitializing(promptId)
|
||||
|
||||
const currentNodeName = computed(() => {
|
||||
const node = executionStore.executingNode
|
||||
if (!node) return t('g.emDash')
|
||||
const title = (node.title ?? '').toString().trim()
|
||||
if (title) return title
|
||||
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
|
||||
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
|
||||
return st(key, nodeType)
|
||||
})
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
|
||||
const allTasksSorted = computed<TaskItemImpl[]>(() => {
|
||||
const all = [
|
||||
...queueStore.pendingTasks,
|
||||
...queueStore.runningTasks,
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
return all.sort((a, b) => b.queueIndex - a.queueIndex)
|
||||
})
|
||||
|
||||
const tasksWithJobState = computed<TaskWithState[]>(() =>
|
||||
allTasksSorted.value.map((task) => ({
|
||||
task,
|
||||
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
|
||||
}))
|
||||
)
|
||||
|
||||
const hasFailedJobs = computed(() =>
|
||||
tasksWithJobState.value.some(({ state }) => state === 'failed')
|
||||
)
|
||||
|
||||
watch(
|
||||
() => hasFailedJobs.value,
|
||||
(hasFailed) => {
|
||||
if (!hasFailed && selectedJobTab.value === 'Failed') {
|
||||
selectedJobTab.value = 'All'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredTaskEntries = computed<TaskWithState[]>(() => {
|
||||
let entries = tasksWithJobState.value
|
||||
if (selectedJobTab.value === 'Completed') {
|
||||
entries = entries.filter(({ state }) => state === 'completed')
|
||||
} else if (selectedJobTab.value === 'Failed') {
|
||||
entries = entries.filter(({ state }) => state === 'failed')
|
||||
}
|
||||
|
||||
if (selectedWorkflowFilter.value === 'current') {
|
||||
const activeId = workflowStore.activeWorkflow?.activeState?.id
|
||||
if (!activeId) return []
|
||||
entries = entries.filter(({ task }) => {
|
||||
const wid = task.workflow?.id
|
||||
return !!wid && wid === activeId
|
||||
})
|
||||
}
|
||||
return entries
|
||||
})
|
||||
|
||||
const filteredTasks = computed<TaskItemImpl[]>(() =>
|
||||
filteredTaskEntries.value.map(({ task }) => task)
|
||||
)
|
||||
|
||||
const jobItems = computed<JobListItem[]>(() => {
|
||||
return filteredTaskEntries.value.map(({ task, state }) => {
|
||||
const isActive =
|
||||
String(task.promptId ?? '') ===
|
||||
String(executionStore.activePromptId ?? '')
|
||||
const showAddedHint = shouldShowAddedHint(task, state)
|
||||
|
||||
const display = buildJobDisplay(task, state, {
|
||||
t,
|
||||
locale: locale.value,
|
||||
formatClockTimeFn: formatClockTime,
|
||||
isActive,
|
||||
totalPercent: isActive ? totalPercent.value : undefined,
|
||||
currentNodePercent: isActive ? currentNodePercent.value : undefined,
|
||||
currentNodeName: isActive ? currentNodeName.value : undefined,
|
||||
showAddedHint
|
||||
})
|
||||
|
||||
return {
|
||||
id: String(task.promptId),
|
||||
title: display.primary,
|
||||
meta: display.secondary,
|
||||
state,
|
||||
iconName: display.iconName,
|
||||
iconImageUrl: display.iconImageUrl,
|
||||
showClear: display.showClear,
|
||||
taskRef: task,
|
||||
progressTotalPercent:
|
||||
state === 'running' && isActive ? totalPercent.value : undefined,
|
||||
progressCurrentPercent:
|
||||
state === 'running' && isActive
|
||||
? currentNodePercent.value
|
||||
: undefined,
|
||||
runningNodeName:
|
||||
state === 'running' && isActive ? currentNodeName.value : undefined,
|
||||
executionTimeMs: task.executionTime,
|
||||
computeHours:
|
||||
task.executionTime !== undefined
|
||||
? task.executionTime / 3_600_000
|
||||
: undefined
|
||||
} as JobListItem
|
||||
})
|
||||
})
|
||||
|
||||
const jobItemById = computed(() => {
|
||||
const m = new Map<string, JobListItem>()
|
||||
jobItems.value.forEach((ji) => m.set(ji.id, ji))
|
||||
return m
|
||||
})
|
||||
|
||||
const groupedJobItems = computed<JobGroup[]>(() => {
|
||||
const groups: JobGroup[] = []
|
||||
const index = new Map<string, number>()
|
||||
const localeValue = locale.value
|
||||
for (const { task, state } of filteredTaskEntries.value) {
|
||||
let ts: number | undefined
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
ts = task.executionEndTimestamp
|
||||
} else {
|
||||
ts = task.createTime
|
||||
}
|
||||
const key = ts === undefined ? 'undated' : dateKey(ts)
|
||||
let groupIdx = index.get(key)
|
||||
if (groupIdx === undefined) {
|
||||
const label =
|
||||
ts === undefined
|
||||
? undatedLabel.value
|
||||
: dateLabelForTimestamp(
|
||||
ts,
|
||||
localeValue,
|
||||
relativeTimeFormatter.value
|
||||
)
|
||||
groups.push({ key, label, items: [] })
|
||||
groupIdx = groups.length - 1
|
||||
index.set(key, groupIdx)
|
||||
}
|
||||
const ji = jobItemById.value.get(String(task.promptId))
|
||||
if (ji) groups[groupIdx].items.push(ji)
|
||||
}
|
||||
|
||||
if (selectedSortMode.value === 'totalGenerationTime') {
|
||||
const valueOrDefault = (value: JobListItem['executionTimeMs']) =>
|
||||
typeof value === 'number' && !Number.isNaN(value) ? value : -1
|
||||
const sortByExecutionTimeDesc = (a: JobListItem, b: JobListItem) =>
|
||||
valueOrDefault(b.executionTimeMs) - valueOrDefault(a.executionTimeMs)
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.items.sort(sortByExecutionTimeDesc)
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
return {
|
||||
// filters/state
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
// data sources
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
jobItems,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user