diff --git a/src/components/common/RefreshButton.vue b/src/components/common/RefreshButton.vue new file mode 100644 index 000000000..0237939fb --- /dev/null +++ b/src/components/common/RefreshButton.vue @@ -0,0 +1,53 @@ + + + + diff --git a/src/components/maintenance/StatusTag.vue b/src/components/maintenance/StatusTag.vue new file mode 100644 index 000000000..d9936067a --- /dev/null +++ b/src/components/maintenance/StatusTag.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/maintenance/TaskCard.vue b/src/components/maintenance/TaskCard.vue new file mode 100644 index 000000000..fc466c68e --- /dev/null +++ b/src/components/maintenance/TaskCard.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/components/maintenance/TaskListItem.vue b/src/components/maintenance/TaskListItem.vue new file mode 100644 index 000000000..8f25ef45b --- /dev/null +++ b/src/components/maintenance/TaskListItem.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/components/maintenance/TaskListPanel.vue b/src/components/maintenance/TaskListPanel.vue new file mode 100644 index 000000000..818ed25f8 --- /dev/null +++ b/src/components/maintenance/TaskListPanel.vue @@ -0,0 +1,115 @@ + + + diff --git a/src/components/maintenance/TaskListStatusIcon.vue b/src/components/maintenance/TaskListStatusIcon.vue new file mode 100644 index 000000000..92922dfef --- /dev/null +++ b/src/components/maintenance/TaskListStatusIcon.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/constants/desktopMaintenanceTasks.ts b/src/constants/desktopMaintenanceTasks.ts new file mode 100644 index 000000000..65a9f8962 --- /dev/null +++ b/src/constants/desktopMaintenanceTasks.ts @@ -0,0 +1,144 @@ +import { PrimeIcons } from '@primevue/core' + +import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes' +import { electronAPI } from '@/utils/envUtil' + +const electron = electronAPI() + +const openUrl = (url: string) => { + window.open(url, '_blank') + return true +} + +export const DESKTOP_MAINTENANCE_TASKS: Readonly[] = [ + { + id: 'basePath', + 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.', + description: + 'The base path is the default location where ComfyUI stores data. It is the location fo the python environment, and may also contain models, custom nodes, and other extensions.', + isInstallationFix: true, + button: { + icon: PrimeIcons.QUESTION, + text: 'Select' + } + }, + { + id: 'git', + headerImg: '/assets/images/Git-Logo-White.svg', + execute: () => openUrl('https://git-scm.com/downloads/'), + name: 'Download git', + shortDescription: 'Open the git download page.', + description: + 'Git is required to download and manage custom nodes and other extensions. This fixer simply opens the download page in your browser. You must download and install git manually.', + button: { + icon: PrimeIcons.EXTERNAL_LINK, + text: 'Download' + } + }, + { + id: 'vcRedist', + execute: () => openUrl('https://aka.ms/vs/17/release/vc_redist.x64.exe'), + name: 'Download VC++ Redist', + shortDescription: 'Download the latest VC++ Redistributable runtime.', + description: + 'The Visual C++ runtime libraries are required to run ComfyUI. You will need to download and install this file.', + button: { + icon: PrimeIcons.EXTERNAL_LINK, + text: 'Download' + } + }, + { + id: 'reinstall', + severity: 'danger', + requireConfirm: true, + execute: async () => { + await electron.reinstall() + return true + }, + name: 'Reinstall ComfyUI', + shortDescription: + 'Deletes the desktop app config and load the welcome screen.', + description: + 'Delete the desktop app config, restart the app, and load the installation screen.', + confirmText: 'Delete all saved config and reinstall?', + button: { + icon: PrimeIcons.EXCLAMATION_TRIANGLE, + text: 'Reinstall' + } + }, + { + id: 'pythonPackages', + requireConfirm: true, + execute: async () => { + try { + await electron.uv.installRequirements() + return true + } catch (error) { + return false + } + }, + name: 'Install python packages', + shortDescription: + 'Installs the base python packages required to run ComfyUI.', + errorDescription: + 'Python packages that are required to run ComfyUI are not installed.', + description: + 'This will install the python packages required to run ComfyUI. This includes torch, torchvision, and other dependencies.', + usesTerminal: true, + isInstallationFix: true, + button: { + icon: PrimeIcons.DOWNLOAD, + text: 'Install' + } + }, + { + id: 'uv', + execute: () => + openUrl('https://docs.astral.sh/uv/getting-started/installation/'), + name: 'uv executable', + shortDescription: 'uv installs and maintains the python environment.', + description: + "This will open the download page for Astral's uv tool. uv is used to install python and manage python packages.", + button: { + icon: 'pi pi-asterisk', + text: 'Download' + } + }, + { + id: 'uvCache', + severity: 'danger', + requireConfirm: true, + execute: async () => await electron.uv.clearCache(), + name: 'uv cache', + shortDescription: 'Remove the Astral uv cache of python packages.', + description: + 'This will remove the uv cache directory and its contents. All downloaded python packages will need to be downloaded again.', + confirmText: 'Delete uv cache of python packages?', + isInstallationFix: true, + button: { + icon: PrimeIcons.TRASH, + text: 'Clear cache' + } + }, + { + id: 'venvDirectory', + severity: 'danger', + requireConfirm: true, + execute: async () => await electron.uv.resetVenv(), + name: 'Reset virtual environment', + shortDescription: + 'Remove and recreate the .venv directory. This removes all python packages.', + description: + 'The python environment is where ComfyUI installs python and python packages. It is used to run the ComfyUI server.', + confirmText: 'Delete the .venv directory?', + usesTerminal: true, + isInstallationFix: true, + button: { + icon: PrimeIcons.FOLDER, + text: 'Recreate' + } + } +] as const diff --git a/src/locales/en/main.json b/src/locales/en/main.json index c06f301c4..3ec0d14f9 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -692,5 +692,21 @@ "UPSCALE_MODEL": "UPSCALE_MODEL", "VAE": "VAE", "WEBCAM": "WEBCAM" + }, + "maintenance": { + "allOk": "No issues were detected.", + "status": "Status", + "detected": "Detected", + "refreshing": "Refreshing", + "None": "None", + "OK": "OK", + "Skipped": "Skipped", + "showManual": "Show maintenance tasks", + "confirmTitle": "Are you sure?", + "error": { + "toastTitle": "Task error", + "taskFailed": "Task failed to run.", + "defaultDescription": "An error occurred while running a maintenance task." + } } } \ No newline at end of file diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 056032b40..76b68e02b 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -261,6 +261,22 @@ "maxLength": "Message trop long" } }, + "maintenance": { + "None": "Aucun", + "OK": "OK", + "Skipped": "Ignoré", + "allOk": "Aucun problème détecté.", + "confirmTitle": "Êtes-vous sûr ?", + "detected": "Détecté", + "error": { + "defaultDescription": "Une erreur s'est produite lors de l'exécution d'une tâche de maintenance.", + "taskFailed": "La tâche a échoué.", + "toastTitle": "Erreur de tâche" + }, + "refreshing": "Actualisation", + "showManual": "Afficher les tâches de maintenance", + "status": "Statut" + }, "menu": { "autoQueue": "File d'attente automatique", "batchCount": "Nombre de lots", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 557dcaebb..16d41dca5 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -261,6 +261,22 @@ "maxLength": "メッセージが長すぎます" } }, + "maintenance": { + "None": "なし", + "OK": "OK", + "Skipped": "スキップされました", + "allOk": "問題は検出されませんでした。", + "confirmTitle": "よろしいですか?", + "detected": "検出されました", + "error": { + "defaultDescription": "メンテナンスタスクの実行中にエラーが発生しました。", + "taskFailed": "タスクの実行に失敗しました。", + "toastTitle": "タスクエラー" + }, + "refreshing": "更新中", + "showManual": "メンテナンスタスクを表示", + "status": "ステータス" + }, "menu": { "autoQueue": "自動キュー", "batchCount": "バッチ数", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 2d4831b8c..efd4e4900 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -261,6 +261,22 @@ "maxLength": "메시지가 너무 깁니다" } }, + "maintenance": { + "None": "없음", + "OK": "확인", + "Skipped": "건너뜀", + "allOk": "문제가 발견되지 않았습니다.", + "confirmTitle": "확실합니까?", + "detected": "감지됨", + "error": { + "defaultDescription": "유지 보수 작업을 실행하는 동안 오류가 발생했습니다.", + "taskFailed": "작업 실행에 실패했습니다.", + "toastTitle": "작업 오류" + }, + "refreshing": "새로 고침 중", + "showManual": "유지 보수 작업 보기", + "status": "상태" + }, "menu": { "autoQueue": "자동 실행 큐", "batchCount": "배치 수", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 7cf6df13c..816d0f8cc 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -261,6 +261,22 @@ "maxLength": "Сообщение слишком длинное" } }, + "maintenance": { + "None": "Нет", + "OK": "OK", + "Skipped": "Пропущено", + "allOk": "Проблем не обнаружено.", + "confirmTitle": "Вы уверены?", + "detected": "Обнаружено", + "error": { + "defaultDescription": "Произошла ошибка при выполнении задачи по обслуживанию.", + "taskFailed": "Не удалось выполнить задачу.", + "toastTitle": "Ошибка задачи" + }, + "refreshing": "Обновление", + "showManual": "Показать задачи по обслуживанию", + "status": "Статус" + }, "menu": { "autoQueue": "Автоочередь", "batchCount": "Количество пакетов", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index a6313a6d9..94b44fbcb 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -261,6 +261,22 @@ "maxLength": "消息过长" } }, + "maintenance": { + "None": "无", + "OK": "确定", + "Skipped": "跳过", + "allOk": "未检测到任何问题。", + "confirmTitle": "你确定吗?", + "detected": "检测到", + "error": { + "defaultDescription": "运行维护任务时发生错误。", + "taskFailed": "任务运行失败。", + "toastTitle": "任务错误" + }, + "refreshing": "刷新中", + "showManual": "显示维护任务", + "status": "状态" + }, "menu": { "autoQueue": "自动执行", "batchCount": "批次数量", diff --git a/src/router.ts b/src/router.ts index b9b9f2494..a5de92df5 100644 --- a/src/router.ts +++ b/src/router.ts @@ -104,6 +104,12 @@ const router = createRouter({ name: 'DesktopStartView', component: () => import('@/views/DesktopStartView.vue'), beforeEnter: guardElectronAccess + }, + { + path: 'maintenance', + name: 'MaintenanceView', + component: () => import('@/views/MaintenanceView.vue'), + beforeEnter: guardElectronAccess } ] } diff --git a/src/stores/maintenanceTaskStore.ts b/src/stores/maintenanceTaskStore.ts new file mode 100644 index 000000000..68bb7e93a --- /dev/null +++ b/src/stores/maintenanceTaskStore.ts @@ -0,0 +1,143 @@ +import type { InstallValidation } from '@comfyorg/comfyui-electron-types' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks' +import type { + MaintenanceTask, + MaintenanceTaskState +} from '@/types/desktop/maintenanceTypes' +import { electronAPI } from '@/utils/envUtil' + +/** + * User-initiated maintenance tasks. Currently only used by the desktop app maintenance view. + * + * Includes running state, task list, and execution / refresh logic. + * @returns The maintenance task store + */ +export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => { + /** Refresh should run for at least this long, even if it completes much faster. Ensures refresh feels like it is doing something. */ + const electron = electronAPI() + + // Reactive state + const isRefreshing = ref(false) + const isRunningTerminalCommand = computed(() => + tasks.value + .filter((task) => task.usesTerminal) + .some((task) => getState(task)?.executing) + ) + const isRunningInstallationFix = computed(() => + tasks.value + .filter((task) => task.isInstallationFix) + .some((task) => getState(task)?.executing) + ) + + // Task list + const tasks = ref(DESKTOP_MAINTENANCE_TASKS) + + const taskStates = ref( + new Map( + DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, {}]) + ) + ) + + /** True if any tasks are in an error state. */ + const anyErrors = computed(() => + tasks.value.some((task) => getState(task).state === 'error') + ) + + /** Wraps the execution of a maintenance task, updating state and rethrowing errors. */ + const execute = async (task: MaintenanceTask) => { + const state = getState(task) + + try { + state.executing = true + const success = await task.execute() + if (!success) return false + + state.error = undefined + return true + } catch (error) { + state.error = (error as Error)?.message + throw error + } finally { + state.executing = false + } + } + + /** + * Returns the matching state object for a task. + * @param task Task to get the matching state object for + * @returns The state object for this task + */ + const getState = (task: MaintenanceTask) => taskStates.value.get(task.id)! + + /** + * Updates the task list with the latest validation state. + * @param validationUpdate Update details passed in by electron + */ + const processUpdate = (validationUpdate: InstallValidation) => { + // Type not exported by API + type ValidationState = InstallValidation['basePath'] + // Add index to API type + type IndexedUpdate = InstallValidation & Record + + const update = validationUpdate as IndexedUpdate + isRefreshing.value = true + + // Update each task state + for (const task of tasks.value) { + const state = getState(task) + + state.refreshing = update[task.id] === undefined + // Mark resolved + if (state.state === 'error' && update[task.id] === 'OK') + state.state = 'resolved' + if (update[task.id] === 'OK' && state.state === 'resolved') continue + + if (update[task.id]) state.state = update[task.id] + } + + // Final update + if (!update.inProgress && isRefreshing.value) { + isRefreshing.value = false + + for (const task of tasks.value) { + const state = getState(task) + state.refreshing = false + if (state.state === 'resolved') continue + + state.state = update[task.id] ?? 'skipped' + } + } + } + + /** Clears the resolved status of tasks (when changing filters) */ + const clearResolved = () => { + for (const task of tasks.value) { + const state = getState(task) + if (state?.state === 'resolved') state.state = 'OK' + } + } + + /** @todo Refreshes Electron tasks only. */ + const refreshDesktopTasks = async () => { + isRefreshing.value = true + console.log('Refreshing desktop tasks') + await electron.Validation.validateInstallation(processUpdate) + } + + return { + tasks, + isRefreshing, + isRunningTerminalCommand, + isRunningInstallationFix, + execute, + getState, + processUpdate, + clearResolved, + /** True if any tasks are in an error state. */ + anyErrors, + refreshDesktopTasks + } +}) diff --git a/src/types/desktop/index.d.ts b/src/types/desktop/index.d.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/types/desktop/maintenanceTypes.ts b/src/types/desktop/maintenanceTypes.ts new file mode 100644 index 000000000..46965bb14 --- /dev/null +++ b/src/types/desktop/maintenanceTypes.ts @@ -0,0 +1,62 @@ +import type { VueSeverity } from '../primeVueTypes' + +interface MaintenanceTaskButton { + /** The text to display on the button. */ + text?: string + /** CSS classes used for the button icon, e.g. 'pi pi-external-link' */ + icon?: string +} + +/** A maintenance task, used by the maintenance page. */ +export interface MaintenanceTask { + /** ID string used as i18n key */ + id: string + /** The display name of the task, e.g. Git */ + name: string + /** Short description of the task. */ + shortDescription?: string + /** Description of the task when it is in an error state. */ + errorDescription?: string + /** Description of the task when it is in a warning state. */ + warningDescription?: string + /** Full description of the task when it is in an OK state. */ + description?: string + /** URL to the image to show in card mode. */ + headerImg?: string + /** The button to display on the task card / list item. */ + button?: MaintenanceTaskButton + /** Whether to show a confirmation dialog before running the task. */ + requireConfirm?: boolean + /** The text to display in the confirmation dialog. */ + confirmText?: string + /** Called by onClick to run the actual task. */ + execute: (args?: unknown[]) => boolean | Promise + /** Show the button with `severity="danger"` */ + severity?: VueSeverity + /** Whether this task should display the terminal window when run. */ + usesTerminal?: boolean + /** If `true`, successful completion of this task will refresh install validation and automatically continue if successful. */ + isInstallationFix?: boolean +} + +/** State of a maintenance task, managed by the maintenance task store. */ +export interface MaintenanceTaskState { + /** The current state of the task. */ + state?: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' + /** Whether the task state is currently being refreshed. */ + refreshing?: boolean + /** Whether the task is currently running. */ + executing?: boolean + /** The error message that occurred when the task failed. */ + error?: string +} + +/** The filter options for the maintenance task list. */ +export interface MaintenanceFilter { + /** CSS classes used for the filter button icon, e.g. 'pi pi-cross' */ + icon: string + /** The text to display on the filter button. */ + value: string + /** The tasks to display when this filter is selected. */ + tasks: ReadonlyArray +} diff --git a/src/types/primeVueTypes.ts b/src/types/primeVueTypes.ts new file mode 100644 index 000000000..b3fdb4608 --- /dev/null +++ b/src/types/primeVueTypes.ts @@ -0,0 +1,10 @@ +/** Button, Tag, etc severity type is 'string' instead of this list. */ +export type VueSeverity = + | 'primary' + | 'secondary' + | 'success' + | 'info' + | 'warn' + | 'help' + | 'danger' + | 'contrast' diff --git a/src/utils/refUtil.ts b/src/utils/refUtil.ts new file mode 100644 index 000000000..02089c64a --- /dev/null +++ b/src/utils/refUtil.ts @@ -0,0 +1,29 @@ +import { useTimeout } from '@vueuse/core' +import { type Ref, computed, ref, watch } from 'vue' + +/** + * Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}. + * If set to `false` before {@link minDuration} has passed, it uses a timer to delay the change. + * @param value The default value to set on this ref + * @param minDuration The minimum time that this ref must be `true` for + * @returns A custom boolean vue ref with a minimum activation time + */ +export function useMinLoadingDurationRef( + value: Ref, + minDuration = 250 +) { + const current = ref(value.value) + + const { ready, start } = useTimeout(minDuration, { + controls: true, + immediate: false + }) + + watch(value, (newValue) => { + if (newValue && !current.value) start() + + current.value = newValue + }) + + return computed(() => current.value || !ready.value) +} diff --git a/src/views/MaintenanceView.vue b/src/views/MaintenanceView.vue new file mode 100644 index 000000000..41bc49bd9 --- /dev/null +++ b/src/views/MaintenanceView.vue @@ -0,0 +1,221 @@ + + + + +