Compare commits
34 Commits
devtools/r
...
live-previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee4599368c | ||
|
|
c781421cad | ||
|
|
bdf6d4dea2 | ||
|
|
b8a796212c | ||
|
|
bc553f12be | ||
|
|
6bb35d46c1 | ||
|
|
68c38f0098 | ||
|
|
236247f05f | ||
|
|
87d6d18c57 | ||
|
|
87106ccb95 | ||
|
|
a20fb7d260 | ||
|
|
836cd7f9ba | ||
|
|
acd855601c | ||
|
|
423a2e76bc | ||
|
|
26578981d4 | ||
|
|
38fb53dca8 | ||
|
|
a832141a45 | ||
|
|
d1f0211b61 | ||
|
|
cc42c2967c | ||
|
|
bb51a5aa76 | ||
|
|
674d884e79 | ||
|
|
6f89d9a9f8 | ||
|
|
08b206f191 | ||
|
|
14d94da52b | ||
|
|
a521066b25 | ||
|
|
ada0993572 | ||
|
|
e42715086e | ||
|
|
92968f3f9b | ||
|
|
73692464ef | ||
|
|
61b3ca046a | ||
|
|
f8912ebaf4 | ||
|
|
80b87c1277 | ||
|
|
00fa9b691b | ||
|
|
0cff8eb357 |
1
.github/workflows/release-version-bump.yaml
vendored
@@ -59,7 +59,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@primevue/core": "catalog:",
|
||||
"@primevue/themes": "catalog:",
|
||||
|
||||
@@ -115,19 +115,18 @@ import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { type ModelRef, computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { ModelRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import {
|
||||
PYPI_MIRROR,
|
||||
PYTHON_MIRROR,
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
import MigrationPicker from './MigrationPicker.vue'
|
||||
import MirrorItem from './mirror/MirrorItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const installPath = defineModel<string>('installPath', { required: true })
|
||||
@@ -229,6 +228,10 @@ const validatePath = async (path: string | undefined) => {
|
||||
}
|
||||
if (validation.parentMissing) errors.push(t('install.parentMissing'))
|
||||
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
|
||||
if (validation.isInsideAppInstallDir)
|
||||
errors.push(t('install.insideAppInstallDir'))
|
||||
if (validation.isInsideUpdaterCache)
|
||||
errors.push(t('install.insideUpdaterCache'))
|
||||
|
||||
if (validation.error)
|
||||
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
|
||||
|
||||
@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
|
||||
execute: async () => await electron.setBasePath(),
|
||||
name: 'Base path',
|
||||
shortDescription: 'Change the application base path.',
|
||||
errorDescription: 'Unable to open the base path. Please select a new one.',
|
||||
errorDescription:
|
||||
'The current base path is invalid or unsafe. Please select a new location.',
|
||||
description:
|
||||
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
|
||||
isInstallationFix: true,
|
||||
|
||||
@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
const electron = electronAPI()
|
||||
|
||||
// Reactive state
|
||||
const lastUpdate = ref<InstallValidation | null>(null)
|
||||
const isRefreshing = ref(false)
|
||||
const isRunningTerminalCommand = computed(() =>
|
||||
tasks.value
|
||||
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
.some((task) => getRunner(task)?.executing)
|
||||
)
|
||||
|
||||
const unsafeBasePath = computed(
|
||||
() => lastUpdate.value?.unsafeBasePath === true
|
||||
)
|
||||
const unsafeBasePathReason = computed(
|
||||
() => lastUpdate.value?.unsafeBasePathReason
|
||||
)
|
||||
|
||||
// Task list
|
||||
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
|
||||
|
||||
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
* @param validationUpdate Update details passed in by electron
|
||||
*/
|
||||
const processUpdate = (validationUpdate: InstallValidation) => {
|
||||
lastUpdate.value = validationUpdate
|
||||
const update = validationUpdate as IndexedUpdate
|
||||
isRefreshing.value = true
|
||||
|
||||
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
}
|
||||
|
||||
const execute = async (task: MaintenanceTask) => {
|
||||
return getRunner(task).execute(task)
|
||||
const success = await getRunner(task).execute(task)
|
||||
if (success && task.isInstallationFix) {
|
||||
await refreshDesktopTasks()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
isRefreshing,
|
||||
isRunningTerminalCommand,
|
||||
isRunningInstallationFix,
|
||||
unsafeBasePath,
|
||||
unsafeBasePathReason,
|
||||
execute,
|
||||
getRunner,
|
||||
processUpdate,
|
||||
|
||||
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
|
||||
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
|
||||
|
||||
type ValidationState = {
|
||||
inProgress: boolean
|
||||
installState: string
|
||||
basePath?: ValidationIssueState
|
||||
unsafeBasePath: boolean
|
||||
unsafeBasePathReason: UnsafeReason
|
||||
venvDirectory?: ValidationIssueState
|
||||
pythonInterpreter?: ValidationIssueState
|
||||
pythonPackages?: ValidationIssueState
|
||||
uv?: ValidationIssueState
|
||||
git?: ValidationIssueState
|
||||
vcRedist?: ValidationIssueState
|
||||
upgradePackages?: ValidationIssueState
|
||||
}
|
||||
|
||||
const validationState: ValidationState = {
|
||||
inProgress: false,
|
||||
installState: 'installed',
|
||||
basePath: 'OK',
|
||||
unsafeBasePath: false,
|
||||
unsafeBasePathReason: null,
|
||||
venvDirectory: 'OK',
|
||||
pythonInterpreter: 'OK',
|
||||
pythonPackages: 'OK',
|
||||
uv: 'OK',
|
||||
git: 'OK',
|
||||
vcRedist: 'OK',
|
||||
upgradePackages: 'OK'
|
||||
}
|
||||
|
||||
const createMockElectronAPI = () => {
|
||||
const logListeners: Array<(message: string) => void> = []
|
||||
|
||||
const getValidationUpdate = () => ({
|
||||
...validationState
|
||||
})
|
||||
|
||||
return {
|
||||
getPlatform: () => 'darwin',
|
||||
changeTheme: (_theme: unknown) => {},
|
||||
onLogMessage: (listener: (message: string) => void) => {
|
||||
logListeners.push(listener)
|
||||
},
|
||||
showContextMenu: (_options: unknown) => {},
|
||||
Events: {
|
||||
trackEvent: (_eventName: string, _data?: unknown) => {}
|
||||
},
|
||||
Validation: {
|
||||
onUpdate: (_callback: (update: unknown) => void) => {},
|
||||
async getStatus() {
|
||||
return getValidationUpdate()
|
||||
},
|
||||
async validateInstallation(callback: (update: unknown) => void) {
|
||||
callback(getValidationUpdate())
|
||||
},
|
||||
async complete() {
|
||||
// Only allow completion when the base path is safe
|
||||
return !validationState.unsafeBasePath
|
||||
},
|
||||
dispose: () => {}
|
||||
},
|
||||
setBasePath: () => Promise.resolve(true),
|
||||
reinstall: () => Promise.resolve(),
|
||||
uv: {
|
||||
installRequirements: () => Promise.resolve(),
|
||||
clearCache: () => Promise.resolve(),
|
||||
resetVenv: () => Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
}
|
||||
|
||||
return globalWindow.electronAPI
|
||||
}
|
||||
|
||||
const MaintenanceView = defineAsyncComponent(async () => {
|
||||
ensureElectronAPI()
|
||||
const module = await import('./MaintenanceView.vue')
|
||||
return module.default
|
||||
})
|
||||
|
||||
const meta: Meta<typeof MaintenanceView> = {
|
||||
title: 'Desktop/Views/MaintenanceView',
|
||||
component: MaintenanceView,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'All tasks OK',
|
||||
render: () => ({
|
||||
components: { MaintenanceView },
|
||||
setup() {
|
||||
validationState.inProgress = false
|
||||
validationState.installState = 'installed'
|
||||
validationState.basePath = 'OK'
|
||||
validationState.unsafeBasePath = false
|
||||
validationState.unsafeBasePathReason = null
|
||||
validationState.venvDirectory = 'OK'
|
||||
validationState.pythonInterpreter = 'OK'
|
||||
validationState.pythonPackages = 'OK'
|
||||
validationState.uv = 'OK'
|
||||
validationState.git = 'OK'
|
||||
validationState.vcRedist = 'OK'
|
||||
validationState.upgradePackages = 'OK'
|
||||
ensureElectronAPI()
|
||||
return {}
|
||||
},
|
||||
template: '<MaintenanceView />'
|
||||
})
|
||||
}
|
||||
|
||||
export const UnsafeBasePathOneDrive: Story = {
|
||||
name: 'Unsafe base path (OneDrive)',
|
||||
render: () => ({
|
||||
components: { MaintenanceView },
|
||||
setup() {
|
||||
validationState.inProgress = false
|
||||
validationState.installState = 'installed'
|
||||
validationState.basePath = 'error'
|
||||
validationState.unsafeBasePath = true
|
||||
validationState.unsafeBasePathReason = 'oneDrive'
|
||||
validationState.venvDirectory = 'OK'
|
||||
validationState.pythonInterpreter = 'OK'
|
||||
validationState.pythonPackages = 'OK'
|
||||
validationState.uv = 'OK'
|
||||
validationState.git = 'OK'
|
||||
validationState.vcRedist = 'OK'
|
||||
validationState.upgradePackages = 'OK'
|
||||
ensureElectronAPI()
|
||||
return {}
|
||||
},
|
||||
template: '<MaintenanceView />'
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unsafe migration warning -->
|
||||
<div v-if="taskStore.unsafeBasePath" class="my-4">
|
||||
<p class="flex items-start gap-3 text-neutral-300">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block mb-1">
|
||||
{{ t('maintenance.unsafeMigration.title') }}
|
||||
</strong>
|
||||
<span class="block mb-1">
|
||||
{{ unsafeReasonText }}
|
||||
</span>
|
||||
<span class="block text-sm text-neutral-400">
|
||||
{{ t('maintenance.unsafeMigration.action') }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<TaskListPanel
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
@@ -89,10 +111,10 @@
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
@@ -139,6 +161,27 @@ const filterOptions = ref([
|
||||
/** Filter binding; can be set to show all tasks, or only errors. */
|
||||
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
|
||||
|
||||
const unsafeReasonText = computed(() => {
|
||||
const reason = taskStore.unsafeBasePathReason
|
||||
if (!reason) {
|
||||
return t('maintenance.unsafeMigration.generic')
|
||||
}
|
||||
|
||||
if (reason === 'appInstallDir') {
|
||||
return t('maintenance.unsafeMigration.appInstallDir')
|
||||
}
|
||||
|
||||
if (reason === 'updaterCache') {
|
||||
return t('maintenance.unsafeMigration.updaterCache')
|
||||
}
|
||||
|
||||
if (reason === 'oneDrive') {
|
||||
return t('maintenance.unsafeMigration.oneDrive')
|
||||
}
|
||||
|
||||
return t('maintenance.unsafeMigration.generic')
|
||||
})
|
||||
|
||||
/** If valid, leave the validation window. */
|
||||
const completeValidation = async () => {
|
||||
const isValid = await electron.Validation.complete()
|
||||
|
||||
@@ -85,11 +85,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InstallStage,
|
||||
type InstallStageInfo,
|
||||
type InstallStageName,
|
||||
ProgressStatus
|
||||
import { InstallStage, ProgressStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import type {
|
||||
InstallStageInfo,
|
||||
InstallStageName
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import {
|
||||
NodeLibrarySidebarTab,
|
||||
QueueSidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
@@ -31,7 +30,6 @@ type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
|
||||
class ComfyMenu {
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _queueTab: QueueSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
|
||||
public readonly sideToolbar: Locator
|
||||
@@ -60,11 +58,6 @@ class ComfyMenu {
|
||||
return this._workflowsTab
|
||||
}
|
||||
|
||||
get queueTab() {
|
||||
this._queueTab ??= new QueueSidebarTab(this.page)
|
||||
return this._queueTab
|
||||
}
|
||||
|
||||
get topbar() {
|
||||
this._topbar ??= new Topbar(this.page)
|
||||
return this._topbar
|
||||
|
||||
@@ -148,124 +148,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class QueueSidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
super(page, 'queue')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('.sidebar-content-container', { hasText: 'Queue' })
|
||||
}
|
||||
|
||||
get tasks() {
|
||||
return this.root.locator('[data-virtual-grid-item]')
|
||||
}
|
||||
|
||||
get visibleTasks() {
|
||||
return this.tasks.locator('visible=true')
|
||||
}
|
||||
|
||||
get clearButton() {
|
||||
return this.root.locator('.clear-all-button')
|
||||
}
|
||||
|
||||
get collapseTasksButton() {
|
||||
return this.getToggleExpandButton(false)
|
||||
}
|
||||
|
||||
get expandTasksButton() {
|
||||
return this.getToggleExpandButton(true)
|
||||
}
|
||||
|
||||
get noResultsPlaceholder() {
|
||||
return this.root.locator('.no-results-placeholder')
|
||||
}
|
||||
|
||||
get galleryImage() {
|
||||
return this.page.locator('.galleria-image')
|
||||
}
|
||||
|
||||
private getToggleExpandButton(isExpanded: boolean) {
|
||||
const iconSelector = isExpanded ? '.pi-image' : '.pi-images'
|
||||
return this.root.locator(`.toggle-expanded-button ${iconSelector}`)
|
||||
}
|
||||
|
||||
async open() {
|
||||
await super.open()
|
||||
return this.root.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async close() {
|
||||
await super.close()
|
||||
await this.root.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
async expandTasks() {
|
||||
await this.expandTasksButton.click()
|
||||
await this.collapseTasksButton.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async collapseTasks() {
|
||||
await this.collapseTasksButton.click()
|
||||
await this.expandTasksButton.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async waitForTasks() {
|
||||
return Promise.all([
|
||||
this.tasks.first().waitFor({ state: 'visible' }),
|
||||
this.tasks.last().waitFor({ state: 'visible' })
|
||||
])
|
||||
}
|
||||
|
||||
async scrollTasks(direction: 'up' | 'down') {
|
||||
const scrollToEl =
|
||||
direction === 'up' ? this.tasks.last() : this.tasks.first()
|
||||
await scrollToEl.scrollIntoViewIfNeeded()
|
||||
await this.waitForTasks()
|
||||
}
|
||||
|
||||
async clearTasks() {
|
||||
await this.clearButton.click()
|
||||
const confirmButton = this.page.getByLabel('Delete')
|
||||
await confirmButton.click()
|
||||
await this.noResultsPlaceholder.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Set the width of the tab (out of 100). Must call before opening the tab */
|
||||
async setTabWidth(width: number) {
|
||||
if (width < 0 || width > 100) {
|
||||
throw new Error('Width must be between 0 and 100')
|
||||
}
|
||||
return this.page.evaluate((width) => {
|
||||
localStorage.setItem('queue', JSON.stringify([width, 100 - width]))
|
||||
}, width)
|
||||
}
|
||||
|
||||
getTaskPreviewButton(taskIndex: number) {
|
||||
return this.tasks.nth(taskIndex).getByRole('button')
|
||||
}
|
||||
|
||||
async openTaskPreview(taskIndex: number) {
|
||||
const previewButton = this.getTaskPreviewButton(taskIndex)
|
||||
await previewButton.click()
|
||||
return this.galleryImage.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
getGalleryImage(imageFilename: string) {
|
||||
return this.galleryImage.and(this.page.getByAltText(imageFilename))
|
||||
}
|
||||
|
||||
getTaskImage(imageFilename: string) {
|
||||
return this.tasks.getByAltText(imageFilename)
|
||||
}
|
||||
|
||||
/** Trigger the queue store and tasks to update */
|
||||
async triggerTasksUpdate() {
|
||||
await this.page.evaluate(() => {
|
||||
window['app']['api'].dispatchCustomEvent('status', {
|
||||
exec_info: { queue_remaining: 0 }
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import type { FullConfig } from '@playwright/test'
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { backupPath } from './utils/backupUtils'
|
||||
import { syncDevtools } from './utils/devtoolsSync'
|
||||
|
||||
loadEnv()
|
||||
dotenv.config()
|
||||
|
||||
export default function globalSetup(_: FullConfig) {
|
||||
export default function globalSetup(config: FullConfig) {
|
||||
if (!process.env.CI) {
|
||||
if (process.env.TEST_COMFYUI_DIR) {
|
||||
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||
backupPath([process.env.TEST_COMFYUI_DIR, 'models'], {
|
||||
renameAndReplaceWithScaffolding: true
|
||||
})
|
||||
|
||||
syncDevtools(process.env.TEST_COMFYUI_DIR)
|
||||
} else {
|
||||
console.warn(
|
||||
'Set TEST_COMFYUI_DIR in .env to prevent user data (settings, workflows, etc.) from being overwritten'
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -2,6 +2,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
@@ -516,6 +517,7 @@ This is English documentation.
|
||||
)
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
|
||||
// Select KSampler first
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
@@ -1,210 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe.skip('Queue sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('can display tasks', async ({ comfyPage }) => {
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
|
||||
test('can display tasks after closing then opening', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.close()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
|
||||
test.describe('Virtual scroll', () => {
|
||||
const layouts = [
|
||||
{ description: 'Five columns layout', width: 95, rows: 3, cols: 5 },
|
||||
{ description: 'Three columns layout', width: 55, rows: 3, cols: 3 },
|
||||
{ description: 'Two columns layout', width: 40, rows: 3, cols: 2 }
|
||||
]
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'])
|
||||
.repeat(50)
|
||||
.setupRoutes()
|
||||
})
|
||||
|
||||
layouts.forEach(({ description, width, rows, cols }) => {
|
||||
const preRenderedRows = 1
|
||||
const preRenderedTasks = preRenderedRows * cols * 2
|
||||
const visibleTasks = rows * cols
|
||||
const expectRenderLimit = visibleTasks + preRenderedTasks
|
||||
|
||||
test.describe(description, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.setTabWidth(width)
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
})
|
||||
|
||||
test('should not render items outside of view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
|
||||
test('should teardown items after scrolling away', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.queueTab.scrollTasks('down')
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
|
||||
test('should re-render items after scrolling away then back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.queueTab.scrollTasks('down')
|
||||
await comfyPage.menu.queueTab.scrollTasks('up')
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expand tasks', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// 2-item batch and 3-item batch -> 3 additional items when expanded
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp', 'example.webp', 'example.webp'])
|
||||
.withTask(['example.webp', 'example.webp'])
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
})
|
||||
|
||||
test('can expand tasks with multiple outputs', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
await comfyPage.menu.queueTab.expandTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
|
||||
initialCount + 3
|
||||
)
|
||||
})
|
||||
|
||||
test('can collapse flat tasks', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
await comfyPage.menu.queueTab.expandTasks()
|
||||
await comfyPage.menu.queueTab.collapseTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
|
||||
initialCount
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Clear tasks', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'])
|
||||
.repeat(6)
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
})
|
||||
|
||||
test('can clear all tasks', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.clearTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(0)
|
||||
expect(
|
||||
await comfyPage.menu.queueTab.noResultsPlaceholder.isVisible()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('can load new tasks after clearing all', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.clearTasks()
|
||||
await comfyPage.menu.queueTab.close()
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Gallery', () => {
|
||||
const firstImage = 'example.webp'
|
||||
const secondImage = 'image32x32.webp'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask([secondImage])
|
||||
.withTask([firstImage])
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
await comfyPage.menu.queueTab.openTaskPreview(0)
|
||||
})
|
||||
|
||||
test('displays gallery image after opening task preview', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.menu.queueTab.getGalleryImage(firstImage)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('maintains active gallery item when new tasks are added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Add a new task while the gallery is still open
|
||||
const newImage = 'image64x64.webp'
|
||||
comfyPage.setupHistory().withTask([newImage])
|
||||
await comfyPage.menu.queueTab.triggerTasksUpdate()
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
|
||||
await newTask.waitFor({ state: 'visible' })
|
||||
// The active gallery item should still be the initial image
|
||||
await expect(
|
||||
comfyPage.menu.queueTab.getGalleryImage(firstImage)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Gallery navigation', () => {
|
||||
const paths: {
|
||||
description: string
|
||||
path: ('Right' | 'Left')[]
|
||||
end: string
|
||||
}[] = [
|
||||
{ description: 'Right', path: ['Right'], end: secondImage },
|
||||
{ description: 'Left', path: ['Right', 'Left'], end: firstImage },
|
||||
{ description: 'Left wrap', path: ['Left'], end: secondImage },
|
||||
{ description: 'Right wrap', path: ['Right', 'Right'], end: firstImage }
|
||||
]
|
||||
|
||||
paths.forEach(({ description, path, end }) => {
|
||||
test(`can navigate gallery ${description}`, async ({ comfyPage }) => {
|
||||
for (const direction of path)
|
||||
await comfyPage.page.keyboard.press(`Arrow${direction}`, {
|
||||
delay: 256
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.menu.queueTab.getGalleryImage(end)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
@@ -6,6 +6,7 @@ import {
|
||||
test.describe('Vue Nodes Zoom', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 142 KiB |
@@ -9,6 +9,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -1,52 +0,0 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
export function syncDevtools(targetComfyDir: string): boolean {
|
||||
if (!targetComfyDir) {
|
||||
console.warn('syncDevtools skipped: TEST_COMFYUI_DIR not set')
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate and sanitize the target directory path
|
||||
const resolvedTargetDir = path.resolve(targetComfyDir)
|
||||
|
||||
// Basic path validation to prevent directory traversal
|
||||
if (resolvedTargetDir.includes('..') || !path.isAbsolute(resolvedTargetDir)) {
|
||||
console.error('syncDevtools failed: Invalid target directory path')
|
||||
return false
|
||||
}
|
||||
|
||||
const moduleDir =
|
||||
typeof __dirname !== 'undefined'
|
||||
? __dirname
|
||||
: path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const devtoolsSrc = path.resolve(moduleDir, '..', '..', 'tools', 'devtools')
|
||||
|
||||
if (!fs.pathExistsSync(devtoolsSrc)) {
|
||||
console.warn(
|
||||
`syncDevtools skipped: source directory not found at ${devtoolsSrc}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const devtoolsDest = path.resolve(
|
||||
resolvedTargetDir,
|
||||
'custom_nodes',
|
||||
'ComfyUI_devtools'
|
||||
)
|
||||
|
||||
console.warn(`syncDevtools: copying ${devtoolsSrc} -> ${devtoolsDest}`)
|
||||
|
||||
try {
|
||||
fs.removeSync(devtoolsDest)
|
||||
fs.ensureDirSync(devtoolsDest)
|
||||
fs.copySync(devtoolsSrc, devtoolsDest, { overwrite: true })
|
||||
console.warn('syncDevtools: copy complete')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync DevTools to ${devtoolsDest}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.32.5",
|
||||
"version": "1.33.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -128,7 +128,7 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "catalog:",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
|
||||
|
||||
--color-interface-panel-job-progress-primary: var(--color-azure-300);
|
||||
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
|
||||
@@ -265,12 +268,13 @@
|
||||
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||
--modal-card-button-surface: var(--color-smoke-300);
|
||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||
--modal-card-tag-background: var(--color-smoke-200);
|
||||
--modal-card-tag-background: var(--color-smoke-400);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-white);
|
||||
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
@@ -330,6 +334,12 @@
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-azure-600);
|
||||
|
||||
/* Queue progress (dark theme) */
|
||||
--color-interface-panel-job-progress-primary: var(--color-cobalt-800);
|
||||
--color-interface-panel-job-progress-secondary: var(
|
||||
--color-alpha-azure-600-30
|
||||
);
|
||||
|
||||
--text-secondary: var(--color-slate-100);
|
||||
--text-primary: var(--color-white);
|
||||
|
||||
@@ -368,9 +378,11 @@
|
||||
--component-node-widget-background-highlighted: var(--color-graphite-400);
|
||||
|
||||
--modal-card-background: var(--secondary-background);
|
||||
--modal-card-background-hovered: var(--secondary-background-hover);
|
||||
--modal-card-border-highlighted: var(--color-ash-400);
|
||||
--modal-card-button-surface: var(--color-charcoal-300);
|
||||
--modal-card-placeholder-background: var(--secondary-background);
|
||||
--modal-card-tag-background: var(--color-charcoal-200);
|
||||
--modal-card-tag-background: var(--color-ash-800);
|
||||
--modal-card-tag-foreground: var(--base-foreground);
|
||||
--modal-panel-background: var(--color-charcoal-600);
|
||||
|
||||
@@ -386,12 +398,14 @@
|
||||
--color-subscription-button-gradient: var(--subscription-button-gradient);
|
||||
|
||||
--color-modal-card-background: var(--modal-card-background);
|
||||
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
|
||||
--color-modal-card-border-highlighted: var(--modal-card-border-highlighted);
|
||||
--color-modal-card-button-surface: var(--modal-card-button-surface);
|
||||
--color-modal-card-placeholder-background: var(--modal-card-placeholder-background);
|
||||
--color-modal-card-tag-background: var(--modal-card-tag-background);
|
||||
--color-modal-card-tag-foreground: var(--modal-card-tag-foreground);
|
||||
--color-modal-panel-background: var(--modal-panel-background);
|
||||
|
||||
|
||||
--color-dialog-surface: var(--dialog-surface);
|
||||
--color-interface-menu-component-surface-hovered: var(
|
||||
--interface-menu-component-surface-hovered
|
||||
@@ -1827,4 +1841,4 @@ audio.comfy-audio.empty-audio-widget {
|
||||
.maskEditor_sidePanelLayerCheckbox {
|
||||
margin-left: 15px;
|
||||
}
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
/* ===================== End of Mask Editor Styles ===================== */
|
||||
|
||||
3
packages/design-system/src/icons/file-output.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.33398 1.33337V4.00004C9.33398 4.35366 9.47446 4.6928 9.72451 4.94285C9.97456 5.1929 10.3137 5.33337 10.6673 5.33337H13.334M2.66732 4.66671V2.66671C2.66732 2.31309 2.80779 1.97395 3.05784 1.7239C3.30789 1.47385 3.64703 1.33337 4.00065 1.33337H10.0006L13.334 4.66671V13.3334C13.334 13.687 13.1935 14.0261 12.9435 14.2762C12.6934 14.5262 12.3543 14.6667 12.0006 14.6667L4.04264 14.666C3.77927 14.7004 3.51166 14.6552 3.2741 14.5365C3.03655 14.4177 2.83988 14.2307 2.70931 13.9994M3.33398 7.33337L1.33398 9.33337M1.33398 9.33337L3.33398 11.3334M1.33398 9.33337H8.00065" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 771 B |
55
pnpm-lock.yaml
generated
@@ -9,6 +9,9 @@ catalogs:
|
||||
'@alloc/quick-lru':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.5.5
|
||||
version: 0.5.5
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.35.0
|
||||
@@ -318,8 +321,8 @@ importers:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.5
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
@@ -709,8 +712,8 @@ importers:
|
||||
apps/desktop-ui:
|
||||
dependencies:
|
||||
'@comfyorg/comfyui-electron-types':
|
||||
specifier: 0.4.73-0
|
||||
version: 0.4.73-0
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.5
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-frontend-utils
|
||||
@@ -1453,8 +1456,8 @@ packages:
|
||||
'@cacheable/utils@2.0.3':
|
||||
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.73-0':
|
||||
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==}
|
||||
'@comfyorg/comfyui-electron-types@0.5.5':
|
||||
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
@@ -4413,6 +4416,9 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7000,6 +7006,11 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
resolve@1.22.11:
|
||||
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
restore-cursor@3.1.0:
|
||||
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7095,6 +7106,11 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.3:
|
||||
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7815,8 +7831,8 @@ packages:
|
||||
vue-component-type-helpers@3.1.1:
|
||||
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
|
||||
|
||||
vue-component-type-helpers@3.1.3:
|
||||
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
|
||||
vue-component-type-helpers@3.1.4:
|
||||
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -8992,7 +9008,7 @@ snapshots:
|
||||
|
||||
'@cacheable/utils@2.0.3': {}
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.4.73-0': {}
|
||||
'@comfyorg/comfyui-electron-types@0.5.5': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
@@ -10617,7 +10633,7 @@ snapshots:
|
||||
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.1.3
|
||||
vue-component-type-helpers: 3.1.4
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -10989,7 +11005,7 @@ snapshots:
|
||||
|
||||
'@types/react@19.1.9':
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/semver@7.7.0': {}
|
||||
|
||||
@@ -12168,6 +12184,8 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
@@ -12594,7 +12612,7 @@ snapshots:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
is-core-module: 2.16.1
|
||||
resolve: 1.22.10
|
||||
resolve: 1.22.11
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
@@ -13740,7 +13758,7 @@ snapshots:
|
||||
acorn: 8.15.0
|
||||
eslint-visitor-keys: 3.4.3
|
||||
espree: 9.6.1
|
||||
semver: 7.7.2
|
||||
semver: 7.7.3
|
||||
|
||||
jsonc-parser@3.2.0: {}
|
||||
|
||||
@@ -15345,6 +15363,13 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
resolve@1.22.11:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
optional: true
|
||||
|
||||
restore-cursor@3.1.0:
|
||||
dependencies:
|
||||
onetime: 5.1.2
|
||||
@@ -15449,6 +15474,8 @@ snapshots:
|
||||
|
||||
semver@7.7.2: {}
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -16343,7 +16370,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.1.1: {}
|
||||
|
||||
vue-component-type-helpers@3.1.3: {}
|
||||
vue-component-type-helpers@3.1.4: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -4,6 +4,7 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@comfyorg/comfyui-electron-types': 0.5.5
|
||||
'@eslint/js': ^9.35.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
|
||||
@@ -4,38 +4,80 @@
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
|
||||
>
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
<ComfyActionbar />
|
||||
<IconButton
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="transparent"
|
||||
size="sm"
|
||||
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
||||
:class="queueHistoryButtonBackgroundClass"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
"
|
||||
@click="toggleQueueOverlay"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--history] block size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="queuedCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
|
||||
>
|
||||
{{ queuedCount }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||
<LoginButton v-else-if="isDesktop" />
|
||||
</div>
|
||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const isDesktop = isElectron()
|
||||
const { t } = useI18n()
|
||||
const isQueueOverlayExpanded = ref(false)
|
||||
const queueStore = useQueueStore()
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const queueHistoryButtonBackgroundClass = computed(() =>
|
||||
isQueueOverlayExpanded.value
|
||||
? 'bg-secondary-background-selected'
|
||||
: 'bg-secondary-background'
|
||||
)
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
@@ -45,6 +87,10 @@ onMounted(() => {
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
}
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto z-1000"
|
||||
class="pointer-events-auto z-1010"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
@@ -260,6 +260,7 @@ const actionbarClass = computed(() =>
|
||||
'w-[265px] border-dashed border-blue-500 opacity-80',
|
||||
'm-1.5 flex items-center justify-center self-stretch',
|
||||
'rounded-md before:w-50 before:-ml-50 before:h-full',
|
||||
'pointer-events-auto',
|
||||
isMouseOverDropZone.value &&
|
||||
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
|
||||
)
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
v-tooltip.bottom="{
|
||||
value: workspaceStore.shiftDown
|
||||
? $t('menu.runWorkflowFront')
|
||||
: $t('menu.runWorkflow'),
|
||||
value: queueButtonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
@@ -12,20 +10,12 @@
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
:disabled="hasMissingNodes"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i v-if="workspaceStore.shiftDown" class="icon-[lucide--list-start]" />
|
||||
<i v-else-if="queueMode === 'disabled'" class="icon-[lucide--play]" />
|
||||
<i
|
||||
v-else-if="queueMode === 'instant'"
|
||||
class="icon-[lucide--fast-forward]"
|
||||
/>
|
||||
<i
|
||||
v-else-if="queueMode === 'change'"
|
||||
class="icon-[lucide--step-forward]"
|
||||
/>
|
||||
<i :class="iconClass" />
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
@@ -42,46 +32,12 @@
|
||||
</template>
|
||||
</SplitButton>
|
||||
<BatchCountEdit />
|
||||
<ButtonGroup class="execution-actions flex flex-nowrap">
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: $t('menu.interrupt'),
|
||||
showDelay: 600
|
||||
}"
|
||||
icon="pi pi-times"
|
||||
:severity="executingPrompt ? 'danger' : 'secondary'"
|
||||
:disabled="!executingPrompt"
|
||||
text
|
||||
:aria-label="$t('menu.interrupt')"
|
||||
@click="() => commandStore.execute('Comfy.Interrupt')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: $t('sideToolbar.queueTab.clearPendingTasks'),
|
||||
showDelay: 600
|
||||
}"
|
||||
icon="pi pi-stop"
|
||||
:severity="hasPendingTasks ? 'danger' : 'secondary'"
|
||||
:disabled="!hasPendingTasks"
|
||||
text
|
||||
:aria-label="$t('sideToolbar.queueTab.clearPendingTasks')"
|
||||
@click="
|
||||
() => {
|
||||
if (queueCountStore.count.value > 1) {
|
||||
commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
queueMode = 'disabled'
|
||||
}
|
||||
"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import ButtonGroup from 'primevue/buttongroup'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import SplitButton from 'primevue/splitbutton'
|
||||
import { computed } from 'vue'
|
||||
@@ -90,18 +46,17 @@ import { useI18n } from 'vue-i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
import BatchCountEdit from '../BatchCountEdit.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
|
||||
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
|
||||
const { t } = useI18n()
|
||||
const queueModeMenuItemLookup = computed(() => {
|
||||
const items: Record<string, MenuItem> = {
|
||||
@@ -152,10 +107,34 @@ const queueModeMenuItems = computed(() =>
|
||||
Object.values(queueModeMenuItemLookup.value)
|
||||
)
|
||||
|
||||
const executingPrompt = computed(() => !!queueCountStore.count.value)
|
||||
const hasPendingTasks = computed(
|
||||
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
|
||||
)
|
||||
const iconClass = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return 'icon-[lucide--list-start]'
|
||||
}
|
||||
if (queueMode.value === 'disabled') {
|
||||
return 'icon-[lucide--play]'
|
||||
}
|
||||
if (queueMode.value === 'instant') {
|
||||
return 'icon-[lucide--fast-forward]'
|
||||
}
|
||||
if (queueMode.value === 'change') {
|
||||
return 'icon-[lucide--step-forward]'
|
||||
}
|
||||
return 'icon-[lucide--play]'
|
||||
})
|
||||
|
||||
const queueButtonTooltip = computed(() => {
|
||||
if (hasMissingNodes.value) {
|
||||
return t('menu.runWorkflowDisabled')
|
||||
}
|
||||
if (workspaceStore.shiftDown) {
|
||||
return t('menu.runWorkflowFront')
|
||||
}
|
||||
return t('menu.runWorkflow')
|
||||
})
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
|
||||
@@ -46,11 +46,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="{
|
||||
value: item.label,
|
||||
value: tooltipText,
|
||||
showDelay: 512
|
||||
}"
|
||||
draggable="false"
|
||||
@@ -16,6 +16,10 @@
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="hasMissingNodes && isRoot"
|
||||
class="icon-[lucide--triangle-alert] text-warning-background"
|
||||
/>
|
||||
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
|
||||
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
@@ -47,7 +51,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { appendJsonExt } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import type { MenuState } from 'primevue/menu'
|
||||
import Menu from 'primevue/menu'
|
||||
@@ -64,6 +67,8 @@ import {
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
@@ -74,6 +79,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
const dialogService = useDialogService()
|
||||
@@ -115,6 +122,14 @@ const rename = async (
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (hasMissingNodes.value && isRoot) {
|
||||
return t('breadcrumbsMenu.missingNodesWarning')
|
||||
}
|
||||
return props.item.label
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
variant?: 'dark' | 'light' | 'gray'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
@@ -19,7 +19,10 @@ const baseClasses =
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground')
|
||||
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground'),
|
||||
gray: cn(
|
||||
'backdrop-blur-[2px] bg-modal-card-tag-background text-base-foreground'
|
||||
)
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
import type { DeviceStats } from '@/schemas/apiSchema'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
device: DeviceStats
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -91,6 +90,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
|
||||
@@ -43,13 +43,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useDownload } from '@/composables/useDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Divider from 'primevue/divider'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabView from 'primevue/tabview'
|
||||
@@ -44,6 +43,7 @@ import { computed } from 'vue'
|
||||
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
|
||||
@@ -23,18 +23,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isValidUrl } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { isValidUrl } from '@/utils/formatUtil'
|
||||
import { checkUrlReachable } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
disableValidation?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -101,6 +102,8 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
|
||||
}
|
||||
|
||||
const validateUrl = async (value: string) => {
|
||||
if (props.disableValidation) return
|
||||
|
||||
if (validationState.value === ValidationState.LOADING) return
|
||||
|
||||
const url = cleanInput(value)
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatMetronomeCurrency } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
const { textClass } = defineProps<{
|
||||
textClass?: string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
|
||||
class="flex w-[490px] flex-col border-t-1 border-border-default"
|
||||
:class="isCloud ? 'border-b-1' : ''"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col gap-4 p-4">
|
||||
<!-- Description -->
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder
|
||||
autofocus
|
||||
@keyup.enter="onConfirm"
|
||||
@focus="selectAllText"
|
||||
@@ -28,6 +29,7 @@ const props = defineProps<{
|
||||
message: string
|
||||
defaultValue: string
|
||||
onConfirm: (value: string) => void
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const inputValue = ref<string>(props.defaultValue)
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatMetronomeCurrency } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
@@ -130,6 +129,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
title: string
|
||||
|
||||
@@ -126,7 +126,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import { FilterMatchMode } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
@@ -147,6 +146,7 @@ import {
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import PanelTemplate from './PanelTemplate.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
@@ -24,6 +23,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
let idleTimeout: number
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
defineProps<{
|
||||
command: ComfyCommand
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { linkifyHtml, nl2br } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -137,7 +137,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatVersionAnchor } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { CSSProperties, Component } from 'vue'
|
||||
@@ -152,6 +151,7 @@ import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
-->
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header mr-4 text-ellipsis"
|
||||
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
|
||||
}
|
||||
|
||||
._sb_node_preview {
|
||||
background-color: var(--comfy-menu-bg);
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
color: var(--descrip-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
|
||||
73
src/components/queue/CompletionSummaryBanner.stories.ts
Normal 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: []
|
||||
}
|
||||
}
|
||||
91
src/components/queue/CompletionSummaryBanner.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
99
src/components/queue/CompletionSummaryBanner.vue
Normal 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>
|
||||
125
src/components/queue/QueueOverlayActive.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
119
src/components/queue/QueueOverlayActive.vue
Normal 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>
|
||||
69
src/components/queue/QueueOverlayEmpty.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
27
src/components/queue/QueueOverlayEmpty.vue
Normal 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>
|
||||
144
src/components/queue/QueueOverlayExpanded.vue
Normal 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>
|
||||
98
src/components/queue/QueueOverlayHeader.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
96
src/components/queue/QueueOverlayHeader.vue
Normal 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>
|
||||
290
src/components/queue/QueueProgressOverlay.vue
Normal 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>
|
||||
88
src/components/queue/dialogs/QueueClearHistoryDialog.vue
Normal 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>
|
||||
72
src/components/queue/job/JobContextMenu.vue
Normal 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>
|
||||
423
src/components/queue/job/JobDetailsPopover.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
350
src/components/queue/job/JobDetailsPopover.vue
Normal 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>
|
||||
224
src/components/queue/job/JobFiltersBar.vue
Normal 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>
|
||||
92
src/components/queue/job/JobGroupsList.vue
Normal 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>
|
||||
65
src/components/queue/job/QueueAssetPreview.vue
Normal 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>
|
||||
134
src/components/queue/job/QueueJobItem.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
310
src/components/queue/job/QueueJobItem.vue
Normal 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>
|
||||
83
src/components/queue/job/useJobErrorReporting.ts
Normal 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
|
||||
}
|
||||
}
|
||||
230
src/components/queue/job/useQueueEstimates.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
149
src/components/queue/job/useQueueEstimates.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -47,10 +47,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
formatNumberWithSuffix,
|
||||
highlightQuery
|
||||
} from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Chip from 'primevue/chip'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
@@ -60,6 +56,7 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import TieredMenu from 'primevue/tieredmenu'
|
||||
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
|
||||
@@ -119,6 +118,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
@@ -97,43 +97,62 @@
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex gap-1 h-18 w-full items-center justify-between"
|
||||
>
|
||||
<div ref="selectionCountButtonRef" class="flex-1 pl-4">
|
||||
<TextButton
|
||||
:label="
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
"
|
||||
type="transparent"
|
||||
@click="handleDeselectAll"
|
||||
/>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<TextButton
|
||||
:label="
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
"
|
||||
type="transparent"
|
||||
:class="isCompact ? 'text-left' : ''"
|
||||
@click="handleDeselectAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 pr-4">
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
:label="$t('mediaAsset.selection.deleteSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<IconButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
:label="$t('mediaAsset.selection.downloadSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<template #icon>
|
||||
</IconButton>
|
||||
<IconButton @click="handleDownloadSelected">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<IconTextButton
|
||||
v-if="shouldShowDeleteButton"
|
||||
:label="$t('mediaAsset.selection.deleteSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
<IconTextButton
|
||||
:label="$t('mediaAsset.selection.downloadSelected')"
|
||||
type="secondary"
|
||||
icon-position="right"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -145,15 +164,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
formatDuration,
|
||||
getMediaTypeFromFilename
|
||||
} from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import { useDebounceFn, useElementHover } from '@vueuse/core'
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import IconButton from '@/components/button/IconButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import TextButton from '@/components/button/TextButton.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -174,6 +190,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
|
||||
|
||||
@@ -224,6 +241,22 @@ const {
|
||||
|
||||
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold: 350px or less shows icon only
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= 350
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t(`sideToolbar.queueTab.${imageFit}ImagePreview`)"
|
||||
:icon="
|
||||
imageFit === 'cover'
|
||||
? 'pi pi-arrow-down-left-and-arrow-up-right-to-center'
|
||||
: 'pi pi-arrow-up-right-and-arrow-down-left-from-center'
|
||||
"
|
||||
text
|
||||
severity="secondary"
|
||||
class="toggle-expanded-button"
|
||||
@click="toggleImageFit"
|
||||
/>
|
||||
<Button
|
||||
v-if="isInFolderView"
|
||||
v-tooltip.bottom="$t('sideToolbar.queueTab.backToAllTasks')"
|
||||
icon="pi pi-arrow-left"
|
||||
text
|
||||
severity="secondary"
|
||||
class="back-button"
|
||||
@click="exitFolderView"
|
||||
/>
|
||||
<template v-else>
|
||||
<Button
|
||||
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
|
||||
:icon="isExpanded ? 'pi pi-images' : 'pi pi-image'"
|
||||
text
|
||||
severity="secondary"
|
||||
class="toggle-expanded-button"
|
||||
@click="toggleExpanded"
|
||||
/>
|
||||
<Button
|
||||
v-if="queueStore.hasPendingTasks"
|
||||
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
|
||||
icon="pi pi-stop"
|
||||
severity="danger"
|
||||
text
|
||||
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
severity="primary"
|
||||
class="clear-all-button"
|
||||
@click="confirmRemoveAll($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template #body>
|
||||
<VirtualGrid
|
||||
v-if="allTasks?.length"
|
||||
:items="allTasks"
|
||||
:grid-style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
padding: '0.5rem',
|
||||
gap: '0.5rem'
|
||||
}"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<TaskItem
|
||||
:task="item"
|
||||
:is-flat-task="isExpanded || isInFolderView"
|
||||
@contextmenu="handleContextMenu"
|
||||
@preview="handlePreview"
|
||||
@task-output-length-clicked="enterFolderView($event)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div v-else-if="queueStore.isLoading">
|
||||
<ProgressSpinner
|
||||
style="width: 50px; left: 50%; transform: translateX(-50%)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-info-circle"
|
||||
:title="$t('g.noTasksFound')"
|
||||
:message="$t('g.noTasksFoundMessage')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<ConfirmPopup />
|
||||
<ContextMenu ref="menu" :model="menuItems" />
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="allGalleryItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
import SidebarTabTemplate from './SidebarTabTemplate.vue'
|
||||
import ResultGallery from './queue/ResultGallery.vue'
|
||||
import TaskItem from './queue/TaskItem.vue'
|
||||
|
||||
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
|
||||
const confirm = useConfirm()
|
||||
const toast = useToast()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Expanded view: show all outputs in a flat list.
|
||||
const isExpanded = ref(false)
|
||||
const galleryActiveIndex = ref(-1)
|
||||
const allGalleryItems = shallowRef<ResultItemImpl[]>([])
|
||||
// Folder view: only show outputs from a single selected task.
|
||||
const folderTask = ref<TaskItemImpl | null>(null)
|
||||
const isInFolderView = computed(() => folderTask.value !== null)
|
||||
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
|
||||
|
||||
const allTasks = computed(() =>
|
||||
isInFolderView.value
|
||||
? folderTask.value
|
||||
? folderTask.value.flatten()
|
||||
: []
|
||||
: isExpanded.value
|
||||
? queueStore.flatTasks
|
||||
: queueStore.tasks
|
||||
)
|
||||
const updateGalleryItems = () => {
|
||||
allGalleryItems.value = allTasks.value.flatMap((task: TaskItemImpl) => {
|
||||
const previewOutput = task.previewOutput
|
||||
return previewOutput ? [previewOutput] : []
|
||||
})
|
||||
}
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
const removeTask = async (task: TaskItemImpl) => {
|
||||
if (task.isRunning) {
|
||||
await api.interrupt(task.promptId)
|
||||
}
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
|
||||
const removeAllTasks = async () => {
|
||||
await queueStore.clear()
|
||||
}
|
||||
|
||||
const confirmRemoveAll = (event: Event) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
message: 'Do you want to delete all tasks?',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectProps: {
|
||||
label: 'Cancel',
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: 'Delete',
|
||||
severity: 'danger'
|
||||
},
|
||||
accept: async () => {
|
||||
await removeAllTasks()
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Confirmed',
|
||||
detail: 'Tasks deleted',
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const menuTargetTask = ref<TaskItemImpl | null>(null)
|
||||
const menuTargetNode = ref<ComfyNode | null>(null)
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
|
||||
disabled: isExpanded.value || isInFolderView.value
|
||||
},
|
||||
{
|
||||
label: t('g.loadWorkflow'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: () => menuTargetTask.value?.loadWorkflow(app),
|
||||
disabled: isCloud
|
||||
? !menuTargetTask.value?.isHistory
|
||||
: !menuTargetTask.value?.workflow
|
||||
},
|
||||
{
|
||||
label: t('g.goToNode'),
|
||||
icon: 'pi pi-arrow-circle-right',
|
||||
command: () => {
|
||||
if (!menuTargetNode.value) return
|
||||
useLitegraphService().goToNode(menuTargetNode.value.id)
|
||||
},
|
||||
visible: !!menuTargetNode.value
|
||||
}
|
||||
]
|
||||
|
||||
if (menuTargetTask.value?.previewOutput?.mediaType === 'images') {
|
||||
items.push({
|
||||
label: t('g.setAsBackground'),
|
||||
icon: 'pi pi-image',
|
||||
command: () => {
|
||||
const url = menuTargetTask.value?.previewOutput?.url
|
||||
if (url) {
|
||||
void settingStore.set('Comfy.Canvas.BackgroundImage', url)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const handleContextMenu = ({
|
||||
task,
|
||||
event,
|
||||
node
|
||||
}: {
|
||||
task: TaskItemImpl
|
||||
event: Event
|
||||
node: ComfyNode | null
|
||||
}) => {
|
||||
menuTargetTask.value = task
|
||||
menuTargetNode.value = node
|
||||
menu.value?.show(event)
|
||||
}
|
||||
|
||||
const handlePreview = (task: TaskItemImpl) => {
|
||||
updateGalleryItems()
|
||||
galleryActiveIndex.value = allGalleryItems.value.findIndex(
|
||||
(item) => item.url === task.previewOutput?.url
|
||||
)
|
||||
}
|
||||
|
||||
const enterFolderView = (task: TaskItemImpl) => {
|
||||
folderTask.value = task
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
folderTask.value = null
|
||||
}
|
||||
|
||||
const toggleImageFit = async () => {
|
||||
await settingStore.set(
|
||||
IMAGE_FIT,
|
||||
imageFit.value === 'cover' ? 'contain' : 'cover'
|
||||
)
|
||||
}
|
||||
|
||||
watch(allTasks, () => {
|
||||
const isGalleryOpen = galleryActiveIndex.value !== -1
|
||||
if (!isGalleryOpen) return
|
||||
|
||||
const prevLength = allGalleryItems.value.length
|
||||
updateGalleryItems()
|
||||
const lengthChange = allGalleryItems.value.length - prevLength
|
||||
if (!lengthChange) return
|
||||
|
||||
const newIndex = galleryActiveIndex.value + lengthChange
|
||||
galleryActiveIndex.value = Math.max(0, newIndex)
|
||||
})
|
||||
</script>
|
||||
@@ -128,7 +128,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { appendJsonExt } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
import Button from 'primevue/button'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
@@ -151,6 +150,7 @@ import {
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
:value="allGalleryItems"
|
||||
:show-indicators="false"
|
||||
change-item-on-indicator-hover
|
||||
show-item-navigators
|
||||
:show-item-navigators="hasMultiple"
|
||||
full-screen
|
||||
circular
|
||||
:circular="hasMultiple"
|
||||
:show-thumbnails="false"
|
||||
:pt="{
|
||||
mask: {
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
@@ -61,6 +61,8 @@ const props = defineProps<{
|
||||
activeIndex: number
|
||||
}>()
|
||||
|
||||
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
|
||||
|
||||
let maskMouseDownTarget: EventTarget | null = null
|
||||
|
||||
const onMaskMouseDown = (event: MouseEvent) => {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="resultContainer"
|
||||
class="result-container"
|
||||
@click="handlePreviewClick"
|
||||
>
|
||||
<ComfyImage
|
||||
v-if="result.isImage"
|
||||
:src="result.url"
|
||||
class="task-output-image"
|
||||
:contain="imageFit === 'contain'"
|
||||
:alt="result.filename"
|
||||
/>
|
||||
<ResultVideo v-else-if="result.isVideo" :result="result" />
|
||||
<ResultAudio v-else-if="result.isAudio" :result="result" />
|
||||
<div v-else class="task-result-preview">
|
||||
<i class="pi pi-file" />
|
||||
<span>{{ result.mediaType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'preview', result: ResultItemImpl): void
|
||||
}>()
|
||||
|
||||
const resultContainer = ref<HTMLElement | null>(null)
|
||||
const settingStore = useSettingStore()
|
||||
const imageFit = computed<string>(() =>
|
||||
settingStore.get('Comfy.Queue.ImageFit')
|
||||
)
|
||||
|
||||
const handlePreviewClick = () => {
|
||||
if (props.result.supportsPreview) {
|
||||
emit('preview', props.result)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.result.mediaType === 'images') {
|
||||
resultContainer.value?.querySelectorAll('img').forEach((img) => {
|
||||
img.draggable = true
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.result-container:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
</style>
|
||||