diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx
index 560d5f1eaa..2c1f3ca32e 100644
--- a/web/app/components/header/index.tsx
+++ b/web/app/components/header/index.tsx
@@ -42,10 +42,17 @@ const Header = () => {
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
+ const handleDownloadGraphImportLog = useCallback(() => {
+ void import('@/app/components/workflow/collaboration/core/collaboration-manager')
+ .then(({ collaborationManager }) => {
+ collaborationManager.downloadGraphImportLog()
+ })
+ .catch(() => {})
+ }, [])
const renderLogo = () => (
-
+
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
@@ -91,7 +98,7 @@ const Header = () => {
return (
-
+
{renderLogo()}
/
@@ -105,7 +112,15 @@ const Header = () => {
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && }
{!isCurrentWorkspaceDatasetOperator && }
-
+
+
diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts
index 07d0bddb01..d60cfe9da0 100644
--- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts
+++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts
@@ -78,7 +78,32 @@ type GraphImportLogEntry = {
}
}
+type SetNodesAnomalyReason = 'node_count_decrease' | 'start_removed'
+
+type SetNodesAnomalyLogEntry = {
+ timestamp: number
+ appId: string | null
+ source: string
+ reasons: SetNodesAnomalyReason[]
+ oldCount: number
+ newCount: number
+ removedNodeIds: string[]
+ oldStartNodeIds: string[]
+ newStartNodeIds: string[]
+ oldNodeIds: string[]
+ newNodeIds: string[]
+ visibilityState: DocumentVisibilityState | 'unknown'
+ meta: {
+ leaderId: string | null
+ isLeader: boolean
+ graphViewActive: boolean | null
+ pendingInitialSync: boolean
+ isConnected: boolean
+ }
+}
+
const GRAPH_IMPORT_LOG_LIMIT = 20
+const SET_NODES_ANOMALY_LOG_LIMIT = 100
const toLoroValue = (value: unknown): Value => cloneDeep(value) as Value
const toLoroRecord = (value: unknown): Record
=> cloneDeep(value) as Record
@@ -102,6 +127,7 @@ export class CollaborationManager {
private pendingGraphImportEmit = false
private graphViewActive: boolean | null = null
private graphImportLogs: GraphImportLogEntry[] = []
+ private setNodesAnomalyLogs: SetNodesAnomalyLogEntry[] = []
private pendingImportLog: {
timestamp: number
sources: Set<'nodes' | 'edges'>
@@ -391,7 +417,7 @@ export class CollaborationManager {
this.connect(appId, reactFlowStore)
}
- setNodes = (oldNodes: Node[], newNodes: Node[]): void => {
+ setNodes = (oldNodes: Node[], newNodes: Node[], source = 'collaboration-manager:setNodes'): void => {
if (!this.doc)
return
@@ -399,6 +425,7 @@ export class CollaborationManager {
if (this.isUndoRedoInProgress)
return
+ this.captureSetNodesAnomaly(oldNodes, newNodes, source)
this.syncNodes(oldNodes, newNodes)
this.doc.commit()
}
@@ -480,8 +507,9 @@ export class CollaborationManager {
if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) {
const selectedNodeId = (value.value as { selectedNodeId?: string | null }).selectedNodeId
if (selectedNodeId) {
- const { setNodes } = this.reactFlowStore.getState()
- const nodes = this.reactFlowStore.getState().getNodes()
+ const state = this.reactFlowStore.getState()
+ const { setNodes } = state
+ const nodes = state.getNodes()
const newNodes = nodes.map((n: Node) => ({
...n,
data: {
@@ -489,6 +517,7 @@ export class CollaborationManager {
selected: n.id === selectedNodeId,
},
}))
+ this.captureSetNodesAnomaly(nodes, newNodes, 'reactflow-native:undo-redo-selection-restore')
setNodes(newNodes)
}
}
@@ -791,9 +820,11 @@ export class CollaborationManager {
requestAnimationFrame(() => {
// Get ReactFlow's native setters, not the collaborative ones
const state = reactFlowStore.getState()
+ const previousNodes = state.getNodes()
const updatedNodes = Array.from(this.nodesMap?.values() || []) as Node[]
const updatedEdges = Array.from(this.edgesMap?.values() || []) as Edge[]
// Call ReactFlow's native setters directly to avoid triggering collaboration
+ this.captureSetNodesAnomaly(previousNodes, updatedNodes, 'reactflow-native:undo-apply')
state.setNodes(updatedNodes)
state.setEdges(updatedEdges)
@@ -831,9 +862,11 @@ export class CollaborationManager {
requestAnimationFrame(() => {
// Get ReactFlow's native setters, not the collaborative ones
const state = reactFlowStore.getState()
+ const previousNodes = state.getNodes()
const updatedNodes = Array.from(this.nodesMap?.values() || []) as Node[]
const updatedEdges = Array.from(this.edgesMap?.values() || []) as Edge[]
// Call ReactFlow's native setters directly to avoid triggering collaboration
+ this.captureSetNodesAnomaly(previousNodes, updatedNodes, 'reactflow-native:redo-apply')
state.setNodes(updatedNodes)
state.setEdges(updatedEdges)
@@ -972,6 +1005,7 @@ export class CollaborationManager {
})
// Call ReactFlow's native setter directly to avoid triggering collaboration
+ this.captureSetNodesAnomaly(previousNodes, updatedNodes, 'reactflow-native:import-nodes-map-subscribe')
state.setNodes(updatedNodes)
this.scheduleGraphImportEmit()
@@ -1067,17 +1101,37 @@ export class CollaborationManager {
clearGraphImportLog(): void {
this.graphImportLogs = []
+ this.setNodesAnomalyLogs = []
this.pendingImportLog = null
}
downloadGraphImportLog(): void {
- if (this.graphImportLogs.length === 0)
- return
-
+ const reactFlowState = this.reactFlowStore?.getState()
const payload = {
appId: this.currentAppId,
generatedAt: new Date().toISOString(),
entries: this.graphImportLogs,
+ setNodesAnomalies: this.setNodesAnomalyLogs,
+ summary: {
+ logCount: this.graphImportLogs.length,
+ setNodesAnomalyCount: this.setNodesAnomalyLogs.length,
+ leaderId: this.leaderId,
+ isLeader: this.isLeader,
+ graphViewActive: this.graphViewActive,
+ pendingInitialSync: this.pendingInitialSync,
+ isConnected: this.isConnected(),
+ hasDoc: Boolean(this.doc),
+ hasReactFlowStore: Boolean(this.reactFlowStore),
+ onlineUsersCount: this.onlineUsers.length,
+ crdtCounts: {
+ nodes: this.getNodes().length,
+ edges: this.getEdges().length,
+ },
+ reactFlowCounts: {
+ nodes: reactFlowState?.getNodes().length ?? 0,
+ edges: reactFlowState?.getEdges().length ?? 0,
+ },
+ },
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const appSuffix = this.currentAppId ?? 'unknown'
@@ -1091,6 +1145,54 @@ export class CollaborationManager {
URL.revokeObjectURL(url)
}
+ private captureSetNodesAnomaly(oldNodes: Node[], newNodes: Node[], source: string): void {
+ const oldNodeIds = oldNodes.map(node => node.id)
+ const newNodeIds = newNodes.map(node => node.id)
+ const newNodeIdSet = new Set(newNodeIds)
+ const removedNodeIds = oldNodeIds.filter(nodeId => !newNodeIdSet.has(nodeId))
+
+ const oldStartNodeIds = oldNodes
+ .filter(node => (node.data as CommonNodeType | undefined)?.type === 'start')
+ .map(node => node.id)
+ const newStartNodeIds = newNodes
+ .filter(node => (node.data as CommonNodeType | undefined)?.type === 'start')
+ .map(node => node.id)
+
+ const reasons: SetNodesAnomalyReason[] = []
+ if (newNodes.length < oldNodes.length)
+ reasons.push('node_count_decrease')
+ if (oldStartNodeIds.length > 0 && newStartNodeIds.length === 0)
+ reasons.push('start_removed')
+
+ if (!reasons.length)
+ return
+
+ const entry: SetNodesAnomalyLogEntry = {
+ timestamp: Date.now(),
+ appId: this.currentAppId,
+ source,
+ reasons,
+ oldCount: oldNodes.length,
+ newCount: newNodes.length,
+ removedNodeIds,
+ oldStartNodeIds,
+ newStartNodeIds,
+ oldNodeIds,
+ newNodeIds,
+ visibilityState: typeof document === 'undefined' ? 'unknown' : document.visibilityState,
+ meta: {
+ leaderId: this.leaderId,
+ isLeader: this.isLeader,
+ graphViewActive: this.graphViewActive,
+ pendingInitialSync: this.pendingInitialSync,
+ isConnected: this.isConnected(),
+ },
+ }
+ this.setNodesAnomalyLogs.push(entry)
+ if (this.setNodesAnomalyLogs.length > SET_NODES_ANOMALY_LOG_LIMIT)
+ this.setNodesAnomalyLogs.splice(0, this.setNodesAnomalyLogs.length - SET_NODES_ANOMALY_LOG_LIMIT)
+ }
+
private snapshotReactFlowGraph(): { nodes: Node[], edges: Edge[] } {
if (!this.reactFlowStore) {
return {
diff --git a/web/app/components/workflow/hooks/use-collaborative-workflow.ts b/web/app/components/workflow/hooks/use-collaborative-workflow.ts
index 9d019f981f..267bb2ecb3 100644
--- a/web/app/components/workflow/hooks/use-collaborative-workflow.ts
+++ b/web/app/components/workflow/hooks/use-collaborative-workflow.ts
@@ -39,13 +39,14 @@ export const useCollaborativeWorkflow = () => {
const store = useStoreApi()
const { setNodes: collabSetNodes, setEdges: collabSetEdges } = collaborationManager
- const setNodes = useCallback((newNodes: Node[], shouldBroadcast: boolean = true) => {
+ const setNodes = useCallback((newNodes: Node[], shouldBroadcast: boolean = true, source = 'use-collaborative-workflow:setNodes') => {
const { getNodes, setNodes: reactFlowSetNodes } = store.getState()
if (shouldBroadcast) {
const oldNodes = getNodes()
collabSetNodes(
oldNodes.map(sanitizeNodeForBroadcast),
newNodes.map(sanitizeNodeForBroadcast),
+ source,
)
}
reactFlowSetNodes(newNodes)
diff --git a/web/app/components/workflow/hooks/use-leader-restore.ts b/web/app/components/workflow/hooks/use-leader-restore.ts
index 5e47f025df..fb050641c8 100644
--- a/web/app/components/workflow/hooks/use-leader-restore.ts
+++ b/web/app/components/workflow/hooks/use-leader-restore.ts
@@ -45,7 +45,7 @@ export const usePerformRestore = () => {
const currentNodes = collaborationManager.getNodes()
const currentEdges = collaborationManager.getEdges()
- collaborationManager.setNodes(currentNodes, nodes)
+ collaborationManager.setNodes(currentNodes, nodes, 'leader-restore:apply-graph')
collaborationManager.setEdges(currentEdges, edges)
collaborationManager.refreshGraphSynchronously()
diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts
index 815b4ecc60..7c97025fd6 100644
--- a/web/app/components/workflow/hooks/use-nodes-interactions.ts
+++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts
@@ -627,7 +627,6 @@ export const useNodesInteractions = () => {
)
const { deleteNodeInspectorVars } = useInspectVarsCrud()
-
const handleNodeDelete = useCallback(
(nodeId: string) => {
if (getNodesReadOnly())
@@ -766,7 +765,7 @@ export const useNodesInteractions = () => {
})
draft.splice(currentNodeIndex, 1)
})
- setNodes(newNodes)
+ setNodes(newNodes, true, 'nodes:perform-batch-cascade-delete')
const newEdges = produce(edges, (draft) => {
return draft.filter(
edge =>
@@ -2116,7 +2115,7 @@ export const useNodesInteractions = () => {
const shouldBroadcast = collaborationManager.isConnected()
setEdges(edges, shouldBroadcast)
- setNodes(nodes, shouldBroadcast)
+ setNodes(nodes, shouldBroadcast, 'nodes:history-back')
if (shouldBroadcast)
collaborationManager.emitHistoryAction('undo')
workflowStore.setState({ edgeMenu: undefined })
@@ -2141,7 +2140,7 @@ export const useNodesInteractions = () => {
const shouldBroadcast = collaborationManager.isConnected()
setEdges(edges, shouldBroadcast)
- setNodes(nodes, shouldBroadcast)
+ setNodes(nodes, shouldBroadcast, 'nodes:history-forward')
if (shouldBroadcast)
collaborationManager.emitHistoryAction('redo')
workflowStore.setState({ edgeMenu: undefined })