@@ -65,8 +69,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
+import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
+import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
+ isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel('searchQuery')
@@ -96,6 +103,10 @@ const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
+// Use the composable to get update available nodes
+const { hasUpdateAvailable, updateAvailableNodePacks } =
+ useUpdateAvailableNodes()
+
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
diff --git a/src/composables/nodePack/useUpdateAvailableNodes.ts b/src/composables/nodePack/useUpdateAvailableNodes.ts
new file mode 100644
index 000000000..516ffffdb
--- /dev/null
+++ b/src/composables/nodePack/useUpdateAvailableNodes.ts
@@ -0,0 +1,65 @@
+import { computed, onMounted } from 'vue'
+
+import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
+import { useComfyManagerStore } from '@/stores/comfyManagerStore'
+import type { components } from '@/types/comfyRegistryTypes'
+import { compareVersions, isSemVer } from '@/utils/formatUtil'
+
+/**
+ * Composable to find NodePacks that have updates available
+ * Uses the same filtering approach as ManagerDialogContent.vue
+ * Automatically fetches installed pack data when initialized
+ */
+export const useUpdateAvailableNodes = () => {
+ const comfyManagerStore = useComfyManagerStore()
+ const { installedPacks, isLoading, error, startFetchInstalled } =
+ useInstalledPacks()
+
+ // Check if a pack has updates available (same logic as usePackUpdateStatus)
+ const isOutdatedPack = (pack: components['schemas']['Node']) => {
+ const isInstalled = comfyManagerStore.isPackInstalled(pack?.id)
+ if (!isInstalled) return false
+
+ const installedVersion = comfyManagerStore.getInstalledPackVersion(
+ pack.id ?? ''
+ )
+ const latestVersion = pack.latest_version?.version
+
+ const isNightlyPack = !!installedVersion && !isSemVer(installedVersion)
+
+ if (isNightlyPack || !latestVersion) {
+ return false
+ }
+
+ return compareVersions(latestVersion, installedVersion) > 0
+ }
+
+ // Same filtering logic as ManagerDialogContent.vue
+ const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
+ packs.filter(isOutdatedPack)
+
+ // Filter only outdated packs from installed packs
+ const updateAvailableNodePacks = computed(() => {
+ if (!installedPacks.value.length) return []
+ return filterOutdatedPacks(installedPacks.value)
+ })
+
+ // Check if there are any outdated packs
+ const hasUpdateAvailable = computed(() => {
+ return updateAvailableNodePacks.value.length > 0
+ })
+
+ // Automatically fetch installed pack data when composable is used
+ onMounted(async () => {
+ if (!installedPacks.value.length && !isLoading.value) {
+ await startFetchInstalled()
+ }
+ })
+
+ return {
+ updateAvailableNodePacks,
+ hasUpdateAvailable,
+ isLoading,
+ error
+ }
+}
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index f281a75db..09cc75d86 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -148,6 +148,8 @@
"uninstalling": "Uninstalling",
"update": "Update",
"uninstallSelected": "Uninstall Selected",
+ "updateSelected": "Update Selected",
+ "updateAll": "Update All",
"updatingAllPacks": "Updating all packages",
"license": "License",
"nightlyVersion": "Nightly",
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index d0577101f..2051d680b 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -635,6 +635,8 @@
"uninstallSelected": "Desinstalar Seleccionado",
"uninstalling": "Desinstalando",
"update": "Actualizar",
+ "updateSelected": "Actualizar seleccionados",
+ "updateAll": "Actualizar todo",
"updatingAllPacks": "Actualizando todos los paquetes",
"version": "Versión"
},
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index cebc51480..0fcab0a16 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -635,6 +635,8 @@
"uninstallSelected": "Désinstaller sélectionné",
"uninstalling": "Désinstallation",
"update": "Mettre à jour",
+ "updateSelected": "Mettre à jour la sélection",
+ "updateAll": "Tout mettre à jour",
"updatingAllPacks": "Mise à jour de tous les paquets",
"version": "Version"
},
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index a1ca9fb9c..09e772478 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -635,6 +635,8 @@
"uninstallSelected": "選択したものをアンインストール",
"uninstalling": "アンインストール中",
"update": "更新",
+ "updateSelected": "選択を更新",
+ "updateAll": "すべて更新",
"updatingAllPacks": "すべてのパッケージを更新中",
"version": "バージョン"
},
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index 99feab40f..634327329 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -635,6 +635,8 @@
"uninstallSelected": "선택 항목 제거",
"uninstalling": "제거 중",
"update": "업데이트",
+ "updateSelected": "선택 항목 업데이트",
+ "updateAll": "전체 업데이트",
"updatingAllPacks": "모든 패키지 업데이트 중",
"version": "버전"
},
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index 5f416ec61..462abdff0 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -635,6 +635,8 @@
"uninstallSelected": "Удалить выбранное",
"uninstalling": "Удаление",
"update": "Обновить",
+ "updateSelected": "Обновить выбранное",
+ "updateAll": "Обновить все",
"updatingAllPacks": "Обновление всех пакетов",
"version": "Версия"
},
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index 97baf5115..0f7499e9d 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -635,6 +635,8 @@
"uninstallSelected": "卸载所选",
"uninstalling": "正在卸载",
"update": "更新",
+ "updateSelected": "更新所选",
+ "updateAll": "全部更新",
"updatingAllPacks": "更新所有包",
"version": "版本"
},
diff --git a/tests-ui/tests/composables/useUpdateAvailableNodes.test.ts b/tests-ui/tests/composables/useUpdateAvailableNodes.test.ts
new file mode 100644
index 000000000..392f9b878
--- /dev/null
+++ b/tests-ui/tests/composables/useUpdateAvailableNodes.test.ts
@@ -0,0 +1,358 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
+import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
+import { useComfyManagerStore } from '@/stores/comfyManagerStore'
+// Import mocked utils
+import { compareVersions, isSemVer } from '@/utils/formatUtil'
+
+// Mock Vue's onMounted to execute immediately for testing
+vi.mock('vue', async () => {
+ const actual = await vi.importActual('vue')
+ return {
+ ...actual,
+ onMounted: (cb: () => void) => cb()
+ }
+})
+
+// Mock the dependencies
+vi.mock('@/composables/nodePack/useInstalledPacks', () => ({
+ useInstalledPacks: vi.fn()
+}))
+
+vi.mock('@/stores/comfyManagerStore', () => ({
+ useComfyManagerStore: vi.fn()
+}))
+
+vi.mock('@/utils/formatUtil', () => ({
+ compareVersions: vi.fn(),
+ isSemVer: vi.fn()
+}))
+
+const mockUseInstalledPacks = vi.mocked(useInstalledPacks)
+const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
+
+const mockCompareVersions = vi.mocked(compareVersions)
+const mockIsSemVer = vi.mocked(isSemVer)
+
+describe('useUpdateAvailableNodes', () => {
+ const mockInstalledPacks = [
+ {
+ id: 'pack-1',
+ name: 'Outdated Pack',
+ latest_version: { version: '2.0.0' }
+ },
+ {
+ id: 'pack-2',
+ name: 'Up to Date Pack',
+ latest_version: { version: '1.0.0' }
+ },
+ {
+ id: 'pack-3',
+ name: 'Nightly Pack',
+ latest_version: { version: '1.5.0' }
+ },
+ {
+ id: 'pack-4',
+ name: 'No Latest Version',
+ latest_version: null
+ }
+ ]
+
+ const mockStartFetchInstalled = vi.fn()
+ const mockIsPackInstalled = vi.fn()
+ const mockGetInstalledPackVersion = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Default setup
+ mockIsPackInstalled.mockReturnValue(true)
+ mockGetInstalledPackVersion.mockImplementation((id: string) => {
+ switch (id) {
+ case 'pack-1':
+ return '1.0.0' // outdated
+ case 'pack-2':
+ return '1.0.0' // up to date
+ case 'pack-3':
+ return 'nightly-abc123' // nightly
+ case 'pack-4':
+ return '1.0.0' // no latest version
+ default:
+ return '1.0.0'
+ }
+ })
+
+ mockIsSemVer.mockImplementation((version: string) => {
+ return !version.includes('nightly')
+ })
+
+ mockCompareVersions.mockImplementation(
+ (latest: string | undefined, installed: string | undefined) => {
+ if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
+ if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
+ return 0
+ }
+ )
+
+ mockUseComfyManagerStore.mockReturnValue({
+ isPackInstalled: mockIsPackInstalled,
+ getInstalledPackVersion: mockGetInstalledPackVersion
+ } as any)
+
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([]),
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+ })
+
+ describe('core filtering logic', () => {
+ it('identifies outdated packs correctly', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref(mockInstalledPacks),
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ // Should only include pack-1 (outdated)
+ expect(updateAvailableNodePacks.value).toHaveLength(1)
+ expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
+ })
+
+ it('excludes up-to-date packs', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ expect(updateAvailableNodePacks.value).toHaveLength(0)
+ })
+
+ it('excludes nightly packs from updates', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ expect(updateAvailableNodePacks.value).toHaveLength(0)
+ })
+
+ it('excludes packs with no latest version', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[3]]), // pack-4: no latest version
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ expect(updateAvailableNodePacks.value).toHaveLength(0)
+ })
+
+ it('excludes uninstalled packs', () => {
+ mockIsPackInstalled.mockReturnValue(false)
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref(mockInstalledPacks),
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ expect(updateAvailableNodePacks.value).toHaveLength(0)
+ })
+
+ it('returns empty array when no installed packs exist', () => {
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ expect(updateAvailableNodePacks.value).toEqual([])
+ })
+ })
+
+ describe('hasUpdateAvailable computed', () => {
+ it('returns true when updates are available', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { hasUpdateAvailable } = useUpdateAvailableNodes()
+
+ expect(hasUpdateAvailable.value).toBe(true)
+ })
+
+ it('returns false when no updates are available', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { hasUpdateAvailable } = useUpdateAvailableNodes()
+
+ expect(hasUpdateAvailable.value).toBe(false)
+ })
+ })
+
+ describe('automatic data fetching', () => {
+ it('fetches installed packs automatically when none exist', () => {
+ useUpdateAvailableNodes()
+
+ expect(mockStartFetchInstalled).toHaveBeenCalledOnce()
+ })
+
+ it('does not fetch when packs already exist', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref(mockInstalledPacks),
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ useUpdateAvailableNodes()
+
+ expect(mockStartFetchInstalled).not.toHaveBeenCalled()
+ })
+
+ it('does not fetch when already loading', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([]),
+ isLoading: ref(true),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ useUpdateAvailableNodes()
+
+ expect(mockStartFetchInstalled).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('state management', () => {
+ it('exposes loading state from useInstalledPacks', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([]),
+ isLoading: ref(true),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { isLoading } = useUpdateAvailableNodes()
+
+ expect(isLoading.value).toBe(true)
+ })
+
+ it('exposes error state from useInstalledPacks', () => {
+ const testError = 'Failed to fetch installed packs'
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([]),
+ isLoading: ref(false),
+ error: ref(testError),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { error } = useUpdateAvailableNodes()
+
+ expect(error.value).toBe(testError)
+ })
+ })
+
+ describe('reactivity', () => {
+ it('updates when installed packs change', async () => {
+ const installedPacksRef = ref([])
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: installedPacksRef,
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks, hasUpdateAvailable } =
+ useUpdateAvailableNodes()
+
+ // Initially empty
+ expect(updateAvailableNodePacks.value).toEqual([])
+ expect(hasUpdateAvailable.value).toBe(false)
+
+ // Update installed packs
+ installedPacksRef.value = [mockInstalledPacks[0]] as any // pack-1: outdated
+ await nextTick()
+
+ // Should update available updates
+ expect(updateAvailableNodePacks.value).toHaveLength(1)
+ expect(hasUpdateAvailable.value).toBe(true)
+ })
+ })
+
+ describe('version comparison logic', () => {
+ it('calls compareVersions with correct parameters', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[0]]), // pack-1
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ // Access the computed to trigger the logic
+ expect(updateAvailableNodePacks.value).toBeDefined()
+
+ expect(mockCompareVersions).toHaveBeenCalledWith('2.0.0', '1.0.0')
+ })
+
+ it('calls isSemVer to check nightly versions', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ // Access the computed to trigger the logic
+ expect(updateAvailableNodePacks.value).toBeDefined()
+
+ expect(mockIsSemVer).toHaveBeenCalledWith('nightly-abc123')
+ })
+
+ it('calls isPackInstalled for each pack', () => {
+ mockUseInstalledPacks.mockReturnValue({
+ installedPacks: ref(mockInstalledPacks),
+ isLoading: ref(false),
+ error: ref(null),
+ startFetchInstalled: mockStartFetchInstalled
+ } as any)
+
+ const { updateAvailableNodePacks } = useUpdateAvailableNodes()
+
+ // Access the computed to trigger the logic
+ expect(updateAvailableNodePacks.value).toBeDefined()
+
+ expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-1')
+ expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-2')
+ expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-3')
+ expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
+ })
+ })
+})