mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 16:40:05 +00:00
fix: detect missing nodes when registry API fails to resolve packs (#9697)
This commit is contained in:
@@ -340,7 +340,8 @@
|
||||
"conflicting": "Conflicting",
|
||||
"inWorkflowSection": "IN WORKFLOW",
|
||||
"allInWorkflow": "All in: {workflowName}",
|
||||
"missingNodes": "Missing Nodes"
|
||||
"missingNodes": "Missing Nodes",
|
||||
"unresolvedNodes": "Unresolved Nodes"
|
||||
},
|
||||
"infoPanelEmpty": "Click an item to see the info",
|
||||
"applyChanges": "Apply Changes",
|
||||
@@ -405,6 +406,10 @@
|
||||
"noDescription": "No description available",
|
||||
"installSelected": "Install Selected",
|
||||
"installAllMissingNodes": "Install All",
|
||||
"unresolvedNodes": {
|
||||
"title": "Unresolved Missing Nodes",
|
||||
"message": "The following nodes are not installed and could not be found in the registry."
|
||||
},
|
||||
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
|
||||
"packsSelected": "packs selected",
|
||||
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
|
||||
|
||||
@@ -128,6 +128,10 @@
|
||||
<div v-if="isLoading" class="size-full scrollbar-hide overflow-auto">
|
||||
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
|
||||
</div>
|
||||
<UnresolvedNodesMessage
|
||||
v-else-if="isUnresolvedTab"
|
||||
:node-names="unresolvedNodeNames"
|
||||
/>
|
||||
<NoResultsPlaceholder
|
||||
v-else-if="displayPacks.length === 0"
|
||||
:title="emptyStateTitle"
|
||||
@@ -199,6 +203,7 @@ import InfoPanel from '@/workbench/extensions/manager/components/manager/infoPan
|
||||
import InfoPanelMultiItem from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelMultiItem.vue'
|
||||
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
|
||||
import GridSkeleton from '@/workbench/extensions/manager/components/manager/skeleton/GridSkeleton.vue'
|
||||
import UnresolvedNodesMessage from '@/workbench/extensions/manager/components/manager/UnresolvedNodesMessage.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
@@ -246,6 +251,7 @@ const {
|
||||
// Missing nodes composable
|
||||
const {
|
||||
missingNodePacks,
|
||||
unresolvedNodeNames,
|
||||
isLoading: isMissingLoading,
|
||||
error: missingError
|
||||
} = useMissingNodes()
|
||||
@@ -309,7 +315,17 @@ const navItems = computed<(NavItemData | NavGroupData)[]>(() => [
|
||||
id: ManagerTab.Missing,
|
||||
label: t('manager.nav.missingNodes'),
|
||||
icon: 'icon-[lucide--triangle-alert]'
|
||||
}
|
||||
},
|
||||
...(unresolvedNodeNames.value.length > 0
|
||||
? [
|
||||
{
|
||||
id: ManagerTab.Unresolved,
|
||||
label: t('manager.nav.unresolvedNodes'),
|
||||
icon: 'icon-[lucide--help-circle]',
|
||||
badge: unresolvedNodeNames.value.length
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -337,6 +353,12 @@ const selectedTab = computed(() =>
|
||||
findNavItemById(navItems.value, selectedNavId.value)
|
||||
)
|
||||
|
||||
watch(navItems, (items) => {
|
||||
if (selectedNavId.value && !findNavItemById(items, selectedNavId.value)) {
|
||||
selectedNavId.value = ManagerTab.Missing
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
pageNumber,
|
||||
@@ -403,6 +425,9 @@ const isUpdateAvailableTab = computed(
|
||||
const isMissingTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Missing
|
||||
)
|
||||
const isUnresolvedTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Unresolved
|
||||
)
|
||||
|
||||
// Map of tab IDs to their empty state i18n key suffixes
|
||||
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-3 p-8">
|
||||
<i class="icon-[lucide--triangle-alert] text-4xl text-warning-background" />
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ $t('manager.unresolvedNodes.title') }}
|
||||
</h3>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('manager.unresolvedNodes.message') }}
|
||||
</p>
|
||||
<ul class="mt-2 flex flex-col gap-1 rounded-lg bg-secondary-background p-2">
|
||||
<li
|
||||
v-for="name in nodeNames"
|
||||
:key="name"
|
||||
class="px-3 py-1.5 font-mono text-sm"
|
||||
>
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
nodeNames: string[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -94,6 +94,7 @@ describe('useMissingNodes', () => {
|
||||
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -119,6 +120,7 @@ describe('useMissingNodes', () => {
|
||||
it('filters out installed packs correctly', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -140,6 +142,7 @@ describe('useMissingNodes', () => {
|
||||
it('returns empty array when all packs are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -158,6 +161,7 @@ describe('useMissingNodes', () => {
|
||||
it('returns all packs when none are installed', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -191,6 +195,7 @@ describe('useMissingNodes', () => {
|
||||
it('fetches even when packs already exist (watch always fires with immediate:true)', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref(mockWorkflowPacks),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -206,6 +211,7 @@ describe('useMissingNodes', () => {
|
||||
it('fetches even when already loading (watch fires regardless of loading state)', async () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -223,6 +229,7 @@ describe('useMissingNodes', () => {
|
||||
it('exposes loading state from useWorkflowPacks', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(true),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -239,6 +246,7 @@ describe('useMissingNodes', () => {
|
||||
const testError = 'Failed to fetch workflow packs'
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(testError),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -257,6 +265,7 @@ describe('useMissingNodes', () => {
|
||||
const workflowPacksRef = ref<WorkflowPack[]>([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -283,6 +292,7 @@ describe('useMissingNodes', () => {
|
||||
const workflowPacksRef = ref(mockWorkflowPacks)
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: workflowPacksRef,
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
@@ -306,6 +316,68 @@ describe('useMissingNodes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('unresolved nodes', () => {
|
||||
it('reports hasMissingNodes when unresolvedNodeNames is non-empty', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref(['UnknownNode1', 'UnknownNode2']),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(true)
|
||||
expect(unresolvedNodeNames.value).toEqual([
|
||||
'UnknownNode1',
|
||||
'UnknownNode2'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report hasMissingNodes when unresolvedNodeNames is empty', () => {
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: ref([]),
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes } = useMissingNodes()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates reactively when unresolvedNodeNames changes', async () => {
|
||||
const unresolvedRef = ref<string[]>([])
|
||||
mockUseWorkflowPacks.mockReturnValue({
|
||||
workflowPacks: ref([]),
|
||||
unresolvedNodeNames: unresolvedRef,
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
|
||||
isReady: ref(true),
|
||||
filterWorkflowPack: vi.fn()
|
||||
})
|
||||
|
||||
const { hasMissingNodes, unresolvedNodeNames } = useMissingNodes()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(false)
|
||||
expect(unresolvedNodeNames.value).toEqual([])
|
||||
|
||||
unresolvedRef.value = ['NewMissingNode']
|
||||
await nextTick()
|
||||
|
||||
expect(hasMissingNodes.value).toBe(true)
|
||||
expect(unresolvedNodeNames.value).toEqual(['NewMissingNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('missing core nodes detection', () => {
|
||||
const createMockNode = (type: string, packId?: string, version?: string) =>
|
||||
createMockLGraphNode({
|
||||
|
||||
@@ -21,8 +21,13 @@ export const useMissingNodes = createSharedComposable(() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
|
||||
useWorkflowPacks()
|
||||
const {
|
||||
workflowPacks,
|
||||
unresolvedNodeNames,
|
||||
isLoading,
|
||||
error,
|
||||
startFetchWorkflowPacks
|
||||
} = useWorkflowPacks()
|
||||
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
@@ -67,7 +72,8 @@ export const useMissingNodes = createSharedComposable(() => {
|
||||
const hasMissingNodes = computed(() => {
|
||||
return (
|
||||
missingNodePacks.value.length > 0 ||
|
||||
Object.keys(missingCoreNodes.value).length > 0
|
||||
Object.keys(missingCoreNodes.value).length > 0 ||
|
||||
unresolvedNodeNames.value.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
@@ -83,6 +89,7 @@ export const useMissingNodes = createSharedComposable(() => {
|
||||
return {
|
||||
missingNodePacks,
|
||||
missingCoreNodes,
|
||||
unresolvedNodeNames,
|
||||
hasMissingNodes,
|
||||
isLoading,
|
||||
error
|
||||
|
||||
@@ -31,6 +31,7 @@ const _useWorkflowPacks = () => {
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
|
||||
const workflowPacks = ref<WorkflowPack[]>([])
|
||||
const unresolvedNodeNames = ref<string[]>([])
|
||||
|
||||
const getWorkflowNodePackId = (node: LGraphNode): string | undefined => {
|
||||
if (typeof node.properties?.cnr_id === 'string') {
|
||||
@@ -111,12 +112,39 @@ const _useWorkflowPacks = () => {
|
||||
|
||||
/**
|
||||
* Get the node packs for all nodes in the workflow (including subgraphs).
|
||||
* Nodes that have no local definition and no registry match are tracked
|
||||
* as unresolved so downstream consumers can surface them to the user.
|
||||
*/
|
||||
const getWorkflowPacks = async () => {
|
||||
if (!app.rootGraph) return []
|
||||
const packPromises = mapAllNodes(app.rootGraph, workflowNodeToPack)
|
||||
const packs = await Promise.all(packPromises)
|
||||
workflowPacks.value = packs.filter((pack) => pack !== undefined)
|
||||
if (!app.rootGraph) {
|
||||
workflowPacks.value = []
|
||||
unresolvedNodeNames.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPacks: WorkflowPack[] = []
|
||||
const unresolved: string[] = []
|
||||
|
||||
await Promise.all(
|
||||
mapAllNodes(app.rootGraph, async (node) => {
|
||||
const pack = await workflowNodeToPack(node)
|
||||
if (pack) {
|
||||
resolvedPacks.push(pack)
|
||||
} else {
|
||||
const nodeName = node.type
|
||||
if (
|
||||
nodeName &&
|
||||
getWorkflowNodePackId(node) === undefined &&
|
||||
!nodeDefStore.nodeDefsByName[nodeName]
|
||||
) {
|
||||
unresolved.push(nodeName)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
workflowPacks.value = resolvedPacks
|
||||
unresolvedNodeNames.value = [...new Set(unresolved)]
|
||||
}
|
||||
|
||||
const packsToUniqueIds = (packs: WorkflowPack[]) =>
|
||||
@@ -147,6 +175,7 @@ const _useWorkflowPacks = () => {
|
||||
isLoading,
|
||||
isReady,
|
||||
workflowPacks: nodePacks,
|
||||
unresolvedNodeNames,
|
||||
startFetchWorkflowPacks: async () => {
|
||||
await getWorkflowPacks() // Parse the packs from the workflow nodes
|
||||
await startFetch() // Fetch the packs infos from the registry
|
||||
|
||||
@@ -176,6 +176,9 @@ export function useManagerDisplayPacks(
|
||||
return sortPacks(filterNotInstalled(base))
|
||||
}
|
||||
|
||||
case ManagerTab.Unresolved:
|
||||
return []
|
||||
|
||||
default:
|
||||
return searchResults.value
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export enum ManagerTab {
|
||||
UpdateAvailable = 'updateAvailable',
|
||||
Conflicting = 'conflicting',
|
||||
Workflow = 'workflow',
|
||||
Missing = 'missing'
|
||||
Missing = 'missing',
|
||||
Unresolved = 'unresolved'
|
||||
}
|
||||
|
||||
export type TaskLog = {
|
||||
|
||||
Reference in New Issue
Block a user