From a84bad9647af84d04f163b1ed954733927c9d32a Mon Sep 17 00:00:00 2001 From: Avinash Kumar Deepak Date: Wed, 8 Apr 2026 19:11:30 +0530 Subject: [PATCH] fix(sync): make server conflict resolution explicit --- src/GraphWorkspace.jsx | 1 + src/component/modals/ConfirmModal.jsx | 26 ++++++- src/component/modals/confirmModal.css | 1 + src/graph-builder/graph-core/6-server.js | 94 +++++++++++++++++++++++- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/GraphWorkspace.jsx b/src/GraphWorkspace.jsx index 53e3504..4045757 100644 --- a/src/GraphWorkspace.jsx +++ b/src/GraphWorkspace.jsx @@ -84,6 +84,7 @@ const GraphComp = (props) => { isOpen={superState.confirmModal.open} title="Confirm" message={superState.confirmModal.message} + actions={superState.confirmModal.actions} onConfirm={() => { if (superState.confirmModal.onConfirm) superState.confirmModal.onConfirm(); dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } }); diff --git a/src/component/modals/ConfirmModal.jsx b/src/component/modals/ConfirmModal.jsx index 787ab33..e564dbf 100644 --- a/src/component/modals/ConfirmModal.jsx +++ b/src/component/modals/ConfirmModal.jsx @@ -3,14 +3,34 @@ import ParentModal from './ParentModal'; import './confirmModal.css'; const ConfirmModal = ({ - isOpen, title, message, onConfirm, onCancel, + isOpen, title, message, onConfirm, onCancel, actions, }) => (
{message}
- - + { + actions && actions.length + ? actions.map((action) => ( + + )) + : ( + <> + + + + ) + }
diff --git a/src/component/modals/confirmModal.css b/src/component/modals/confirmModal.css index 5402b8b..e03e316 100644 --- a/src/component/modals/confirmModal.css +++ b/src/component/modals/confirmModal.css @@ -5,6 +5,7 @@ .confirm-modal-message { margin-bottom: 20px; font-size: 1.1em; + white-space: pre-line; } .confirm-modal-actions { display: flex; diff --git a/src/graph-builder/graph-core/6-server.js b/src/graph-builder/graph-core/6-server.js index d396bbb..15bf43e 100644 --- a/src/graph-builder/graph-core/6-server.js +++ b/src/graph-builder/graph-core/6-server.js @@ -1,7 +1,7 @@ import { toast } from 'react-toastify'; import Axios from 'axios'; import { actionType as T } from '../../reducer'; -import { EXECUTION_ENGINE_URL } from '../../serverCon/config'; +import serverConConfig, { EXECUTION_ENGINE_URL } from '../../serverCon/config'; import GraphLoadSave from './5-load-save'; // import { // postGraph, updateGraph, forceUpdateGraph, getGraph, getGraphWithHashCheck, @@ -11,6 +11,86 @@ import { } from '../../serverCon/crud_http'; class GraphServer extends GraphLoadSave { + static isSyncConflictError(err) { + const msg = (err && err.message ? err.message : '').toLowerCase(); + return msg.includes('different history') + || msg.includes('latest changes') + || msg.includes('can not update'); + } + + showSyncConflictModal(reason) { + const localHash = this.actionArr.length ? this.actionArr.at(-1).hash : 'None'; + const setModal = (remoteHash) => { + const message = [ + reason || 'Sync conflict detected.', + `Local hash: ${localHash}`, + `Remote hash: ${remoteHash || 'Remote history is newer/different'}`, + 'Choose how to resolve this conflict.', + ].join('\n'); + this.dispatcher({ + type: T.SET_CONFIRM_MODAL, + payload: { + open: true, + message, + actions: [ + { + label: 'Pull remote', + className: 'confirm-btn', + onClick: () => this.forcePullFromServer(), + }, + { + label: 'Force push local', + className: 'confirm-btn', + onClick: () => { + if (this.serverID) { + forceUpdateGraph(this.serverID, this.getGraphML()).catch((err) => { + toast.error(err.response?.data?.message || err.message); + }); + } else { + postGraph(this.getGraphML()).then((serverID) => { + this.set({ serverID }); + }).catch((err) => { + toast.error(err.response?.data?.message || err.message); + }); + } + }, + }, + { + label: 'Open remote in new tab', + className: 'cancel-btn', + onClick: () => { + if (!this.serverID) return; + const remotePath = serverConConfig.getGraph(this.serverID); + const remoteURL = `${serverConConfig.baseURL}${remotePath}`; + window.open(remoteURL, '_blank', 'noopener,noreferrer'); + }, + }, + { + label: 'Cancel', + className: 'cancel-btn', + onClick: null, + }, + ], + }, + }); + }; + if (!this.serverID) { + setModal('Unknown'); + return; + } + getGraph(this.serverID).then((graphXML) => { + try { + const doc = new DOMParser().parseFromString(graphXML, 'application/xml'); + const hashes = doc.getElementsByTagName('hash'); + setModal(hashes.length ? (hashes[hashes.length - 1].textContent || '') : ''); + } catch { + setModal('Unavailable'); + } + }).catch(() => { + setModal('Unavailable'); + }); + } + set(config) { const { serverID } = config; super.set(config); @@ -73,6 +153,10 @@ class GraphServer extends GraphLoadSave { updateGraph(this.serverID, this.getGraphML()).then(() => { }).catch((err) => { + if (GraphServer.isSyncConflictError(err)) { + this.showSyncConflictModal('Cannot push: local and remote histories diverged.'); + return; + } toast.error(err.response?.data?.message || err.message); }); } else { @@ -127,8 +211,12 @@ class GraphServer extends GraphLoadSave { if (this.serverID) { getGraphWithHashCheck(this.serverID, this.actionArr.at(-1).hash).then((graphXML) => { this.setGraphML(graphXML); - }).catch(() => { - + }).catch((err) => { + if (GraphServer.isSyncConflictError(err)) { + this.showSyncConflictModal('Cannot pull: local and remote histories diverged.'); + return; + } + toast.error(err.response?.data?.message || err.message); }); } else { toast.success('Not on server');