diff --git a/server/controller/workflow.py b/server/controller/workflow.py index ed79375..6df2389 100644 --- a/server/controller/workflow.py +++ b/server/controller/workflow.py @@ -1,5 +1,5 @@ from model.workflows import WorkFlowModel -from flask import request, make_response, Blueprint +from flask import request, make_response, Blueprint, jsonify import defusedxml.ElementTree as ET workFlow = Blueprint('workflow', __name__) @@ -26,6 +26,15 @@ def getAllActionHash(root): return list(map(lambda ah: ah.find(f'{{{xmlns}}}hash').text, root.find(f'{{{xmlns}}}graph').findall(f'{{{xmlns}}}actionHistory'))) +def syncConflict(message='Different History'): + return jsonify({'code': 'SYNC_CONFLICT', 'message': message}), 400 + + +def isConflictMessage(message): + msg = (message or '').lower() + return 'latest changes' in msg or 'different history' in msg + + @workFlow.route("/", methods=['POST']) def postWorkflow(): try: @@ -45,7 +54,7 @@ def getWorkflow(serverID): latestHash = request.headers['X-Latest-Hash'] allHash = getAllActionHash(ET.fromstring(graphml)) if(latestHash not in allHash): - return 'Different History', 400 + return syncConflict() r = make_response(graphml) r.headers.set('Content-Type', "application/xml") return r @@ -67,4 +76,8 @@ def updateWorkflow(serverID): res = workFlowModel.forceUpdate(serverID, graphML, latestHash) else: res = workFlowModel.update(serverID, graphML, latestHash, allHash) - return res[1], 200 if res[0] else 400 + if res[0]: + return res[1], 200 + if isConflictMessage(res[1]): + return syncConflict(res[1]) + return res[1], 400 diff --git a/server/tests/test_workflow_controller.py b/server/tests/test_workflow_controller.py index 38c5ab0..c964e02 100644 --- a/server/tests/test_workflow_controller.py +++ b/server/tests/test_workflow_controller.py @@ -32,6 +32,11 @@ def forceUpdate(self, serverID, graphml, latestHash): return (True, latestHash) +class FakeWorkFlowModelUpdateMissing(FakeWorkFlowModel): + def update(self, serverID, graphml, latestHash, allHash): + return (False, 'serverID do not exists.') + + class WorkflowControllerTests(unittest.TestCase): @classmethod def setUpClass(cls): @@ -62,6 +67,12 @@ def make_client(self, graph_response): app.register_blueprint(self.workflow_module.workFlow, url_prefix='/workflow') return app.test_client() + def make_client_with_model(self, model): + self.workflow_module.workFlowModel = model + app = Flask(__name__) + app.register_blueprint(self.workflow_module.workFlow, url_prefix='/workflow') + return app.test_client() + def test_missing_workflow_returns_404_for_none(self): client = self.make_client(None) response = client.get('/workflow/missing-id') @@ -78,7 +89,8 @@ def test_hash_header_returns_400_for_different_history(self): client = self.make_client(VALID_GRAPHML) response = client.get('/workflow/existing-id', headers={'X-Latest-Hash': 'unknown-hash'}) self.assertEqual(response.status_code, 400) - self.assertEqual(response.get_data(as_text=True), 'Different History') + self.assertEqual(response.get_json()['code'], 'SYNC_CONFLICT') + self.assertEqual(response.get_json()['message'], 'Different History') def test_hash_header_returns_200_for_matching_history(self): client = self.make_client(VALID_GRAPHML) @@ -117,6 +129,13 @@ def test_force_update_workflow_returns_200(self): content_type='application/xml') self.assertEqual(response.status_code, 200) + def test_update_workflow_non_conflict_error_returns_plain_400(self): + client = self.make_client_with_model(FakeWorkFlowModelUpdateMissing(None)) + response = client.post('/workflow/test01', data=VALID_GRAPHML, + content_type='application/xml') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.get_data(as_text=True), 'serverID do not exists.') + if __name__ == '__main__': unittest.main() diff --git a/src/GraphWorkspace.jsx b/src/GraphWorkspace.jsx index aa9b640..78c85f6 100644 --- a/src/GraphWorkspace.jsx +++ b/src/GraphWorkspace.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ZoomComp from './component/ZoomSetter'; import ConfirmModal from './component/modals/ConfirmModal'; +import SyncStatusPanel from './component/SyncStatusPanel'; import SearchPanel from './component/SearchPanel'; import { actionType as T } from './reducer'; import './graphWorkspace.css'; @@ -129,6 +130,7 @@ const GraphComp = (props) => { ))} + { + if (superState.curGraphIndex === -1) return null; + + const graph = superState.graphs[superState.curGraphIndex]; + if (!graph) return null; + + const syncStatus = graph.syncStatus || {}; + const { + state = 'dirty', + localHash = '', + remoteHash = '', + lastResult = 'Not synced yet', + } = syncStatus; + + const instance = superState.curGraphInstance; + const isConflict = state === 'conflict'; + + return ( +
+
+ Sync + {stateLabel[state] || state} +
+
+
+ Local: + {localHash || '-'} +
+
+ Remote: + {remoteHash || '-'} +
+
+
{lastResult}
+ {isConflict && ( +
+ + + + +
+ )} +
+ ); +}; + +export default SyncStatusPanel; diff --git a/src/component/syncStatusPanel.css b/src/component/syncStatusPanel.css new file mode 100644 index 0000000..308200b --- /dev/null +++ b/src/component/syncStatusPanel.css @@ -0,0 +1,81 @@ +.sync-status-panel { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 5; + width: 320px; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 10px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); + font-size: 12px; +} + +.sync-status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.sync-status-badge { + font-size: 11px; + border-radius: 10px; + padding: 2px 8px; + background: #666; +} + +.sync-status-badge.synced { + background: #2e7d32; +} + +.sync-status-badge.dirty { + background: #ef6c00; +} + +.sync-status-badge.conflict { + background: #c62828; +} + +.sync-status-badge.syncing { + background: #1565c0; +} + +.sync-status-badge.error { + background: #7b1fa2; +} + +.sync-status-meta { + display: grid; + grid-template-columns: 1fr; + row-gap: 4px; + margin-bottom: 8px; +} + +.sync-key { + color: var(--text-primary); + opacity: 0.75; + margin-right: 4px; +} + +.sync-value { + word-break: break-all; +} + +.sync-status-result { + margin-bottom: 10px; +} + +.sync-status-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.sync-status-actions .confirm-btn, +.sync-status-actions .cancel-btn { + padding: 6px 8px; + font-size: 12px; +} diff --git a/src/graph-builder/graph-core/6-server.js b/src/graph-builder/graph-core/6-server.js index 15bf43e..29c7982 100644 --- a/src/graph-builder/graph-core/6-server.js +++ b/src/graph-builder/graph-core/6-server.js @@ -11,16 +11,104 @@ import { } from '../../serverCon/crud_http'; class GraphServer extends GraphLoadSave { + static getLatestHashFromGraphML(graphXML) { + if (!graphXML) return ''; + try { + const doc = new DOMParser().parseFromString(graphXML, 'application/xml'); + const hashes = doc.getElementsByTagName('hash'); + if (!hashes.length) return ''; + return hashes[hashes.length - 1].textContent || ''; + } catch { + return ''; + } + } + + static getErrorMessage(err) { + return err?.data?.message || err?.response?.data?.message || err?.message || 'Sync failed'; + } + 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'); + return err?.code === 'SYNC_CONFLICT' + || err?.data?.code === 'SYNC_CONFLICT' + || (err?.status === 400 && err?.body === 'Different History'); + } + + getLocalHash() { + return this.actionArr.length ? this.actionArr.at(-1).hash : ''; + } + + setSyncStatus(syncStatus) { + this.dispatcher({ + type: T.SET_GRAPH_SYNC_STATE, + payload: { + graphID: this.id, + syncStatus, + }, + }); + } + + setSyncStateFromSavedFlag() { + const state = this.serverID && this.isSaved ? 'synced' : 'dirty'; + let lastResult = 'Local workflow.'; + if (this.serverID) { + lastResult = this.isSaved ? 'Synced.' : 'Local changes pending sync.'; + } + this.setSyncStatus({ + state, + localHash: this.getLocalHash(), + lastResult, + }); + } + + setSyncError(err) { + const message = GraphServer.getErrorMessage(err); + this.setSyncStatus({ + state: 'error', + localHash: this.getLocalHash(), + lastResult: message, + reason: message, + }); + toast.error(message); + } + + syncSuccessFromGraphML(resultText, graphXML) { + const remoteHash = GraphServer.getLatestHashFromGraphML(graphXML); + this.setSyncStatus({ + state: 'synced', + localHash: remoteHash || this.getLocalHash(), + remoteHash: remoteHash || this.getLocalHash(), + lastResult: resultText, + reason: '', + }); + } + + openRemoteInNewTab() { + if (!this.serverID) return; + const remotePath = serverConConfig.getGraph(this.serverID); + const remoteURL = `${serverConConfig.baseURL}${remotePath}`; + window.open(remoteURL, '_blank', 'noopener,noreferrer'); + } + + cancelSyncConflict() { + const state = this.serverID && this.isSaved ? 'synced' : 'dirty'; + this.setSyncStatus({ + state, + localHash: this.getLocalHash(), + lastResult: 'Conflict dismissed.', + reason: '', + }); } showSyncConflictModal(reason) { - const localHash = this.actionArr.length ? this.actionArr.at(-1).hash : 'None'; + const localHash = this.getLocalHash() || 'None'; const setModal = (remoteHash) => { + this.setSyncStatus({ + state: 'conflict', + localHash: this.getLocalHash(), + remoteHash: remoteHash || '', + lastResult: reason || 'Sync conflict detected.', + reason: reason || 'Sync conflict detected.', + }); const message = [ reason || 'Sync conflict detected.', `Local hash: ${localHash}`, @@ -42,15 +130,23 @@ class GraphServer extends GraphLoadSave { label: 'Force push local', className: 'confirm-btn', onClick: () => { + this.setSyncStatus({ + state: 'syncing', + localHash: this.getLocalHash(), + lastResult: 'Force pushing local changes...', + }); if (this.serverID) { - forceUpdateGraph(this.serverID, this.getGraphML()).catch((err) => { - toast.error(err.response?.data?.message || err.message); + forceUpdateGraph(this.serverID, this.getGraphML()).then(() => { + this.syncSuccessFromGraphML('Force push successful.', this.getGraphML()); + }).catch((err) => { + this.setSyncError(err); }); } else { postGraph(this.getGraphML()).then((serverID) => { this.set({ serverID }); + this.syncSuccessFromGraphML('Force push successful.', this.getGraphML()); }).catch((err) => { - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } }, @@ -58,17 +154,12 @@ class GraphServer extends GraphLoadSave { { 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'); - }, + onClick: () => this.openRemoteInNewTab(), }, { label: 'Cancel', className: 'cancel-btn', - onClick: null, + onClick: () => this.cancelSyncConflict(), }, ], }, @@ -97,6 +188,11 @@ class GraphServer extends GraphLoadSave { if (serverID) { this.setServerID(serverID); this.dispatcher({ type: T.IS_WORKFLOW_ON_SERVER, payload: Boolean(this.serverID) }); + this.setSyncStatus({ + state: 'dirty', + localHash: this.getLocalHash(), + lastResult: 'Connected to server.', + }); } } // Not being immplemented in version 1 @@ -149,22 +245,28 @@ class GraphServer extends GraphLoadSave { // } pushToServer() { + this.setSyncStatus({ + state: 'syncing', + localHash: this.getLocalHash(), + lastResult: 'Pushing local changes...', + }); if (this.serverID) { updateGraph(this.serverID, this.getGraphML()).then(() => { - + this.syncSuccessFromGraphML('Push successful.', this.getGraphML()); }).catch((err) => { if (GraphServer.isSyncConflictError(err)) { this.showSyncConflictModal('Cannot push: local and remote histories diverged.'); return; } - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } else { postGraph(this.getGraphML()).then((serverID) => { this.set({ serverID }); this.cy.emit('graph-modified'); + this.syncSuccessFromGraphML('Push successful.', this.getGraphML()); }).catch((err) => { - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } } @@ -176,17 +278,23 @@ class GraphServer extends GraphLoadSave { open: true, message: 'Forced push may result in workflow overwite and loss of changes pushed by others. Confirm?', onConfirm: () => { + this.setSyncStatus({ + state: 'syncing', + localHash: this.getLocalHash(), + lastResult: 'Force pushing local changes...', + }); if (this.serverID) { forceUpdateGraph(this.serverID, this.getGraphML()).then(() => { - + this.syncSuccessFromGraphML('Force push successful.', this.getGraphML()); }).catch((err) => { - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } else { postGraph(this.getGraphML()).then((serverID) => { this.set({ serverID }); + this.syncSuccessFromGraphML('Force push successful.', this.getGraphML()); }).catch((err) => { - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } }, @@ -196,10 +304,16 @@ class GraphServer extends GraphLoadSave { forcePullFromServer() { if (this.serverID) { + this.setSyncStatus({ + state: 'syncing', + localHash: this.getLocalHash(), + lastResult: 'Pulling remote workflow...', + }); getGraph(this.serverID).then((graphXML) => { this.setGraphML(graphXML); + this.syncSuccessFromGraphML('Pull successful.', graphXML); }).catch((err) => { - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } else { toast.success('Not on server'); @@ -209,14 +323,20 @@ class GraphServer extends GraphLoadSave { pullFromServer() { if (this.actionArr.length === 0) { this.forcePullFromServer(); return; } if (this.serverID) { + this.setSyncStatus({ + state: 'syncing', + localHash: this.getLocalHash(), + lastResult: 'Checking remote changes...', + }); getGraphWithHashCheck(this.serverID, this.actionArr.at(-1).hash).then((graphXML) => { this.setGraphML(graphXML); + this.syncSuccessFromGraphML('Pull successful.', graphXML); }).catch((err) => { if (GraphServer.isSyncConflictError(err)) { this.showSyncConflictModal('Cannot pull: local and remote histories diverged.'); return; } - toast.error(err.response?.data?.message || err.message); + this.setSyncError(err); }); } else { toast.success('Not on server'); @@ -336,6 +456,12 @@ class GraphServer extends GraphLoadSave { setCurStatus() { super.setCurStatus(); this.dispatcher({ type: T.IS_WORKFLOW_ON_SERVER, payload: Boolean(this.serverID) }); + const currentGraph = this.superState.graphs.find((g) => g.graphID === this.id); + if (!currentGraph || !currentGraph.syncStatus || currentGraph.syncStatus.state !== 'conflict') { + this.setSyncStateFromSavedFlag(); + } else { + this.setSyncStatus({ localHash: this.getLocalHash() }); + } } } diff --git a/src/reducer/actionType.js b/src/reducer/actionType.js index 5fdf0da..297434f 100644 --- a/src/reducer/actionType.js +++ b/src/reducer/actionType.js @@ -52,6 +52,7 @@ const actionType = { SET_SEARCH_QUERY: 'SET_SEARCH_QUERY', SET_SEARCH_RESULTS: 'SET_SEARCH_RESULTS', SET_SEARCH_INDEX: 'SET_SEARCH_INDEX', + SET_GRAPH_SYNC_STATE: 'SET_GRAPH_SYNC_STATE', }; export default zealit(actionType); diff --git a/src/reducer/initialState.js b/src/reducer/initialState.js index bb53c35..ede9764 100644 --- a/src/reducer/initialState.js +++ b/src/reducer/initialState.js @@ -66,6 +66,13 @@ const initialGraphState = { destroyed: false, cleared: false, stopped: false, + syncStatus: { + state: 'dirty', + localHash: '', + remoteHash: '', + lastResult: 'Not synced yet', + reason: '', + }, }; export { initialState, initialGraphState }; diff --git a/src/reducer/reducer.js b/src/reducer/reducer.js index 4d8970f..51d987c 100644 --- a/src/reducer/reducer.js +++ b/src/reducer/reducer.js @@ -270,6 +270,22 @@ const reducer = (state, action) => { return { ...newState }; } + case T.SET_GRAPH_SYNC_STATE: { + const newState = { ...state }; + newState.graphs = newState.graphs.map((g) => ( + g.graphID === action.payload.graphID + ? { + ...g, + syncStatus: { + ...(g.syncStatus || initialGraphState.syncStatus), + ...action.payload.syncStatus, + }, + } + : g + )); + return { ...newState }; + } + case T.TOGGLE_DARK_MODE: { return { ...state, darkMode: !state.darkMode }; } diff --git a/src/reducer/reducer.sync.test.js b/src/reducer/reducer.sync.test.js new file mode 100644 index 0000000..dab2815 --- /dev/null +++ b/src/reducer/reducer.sync.test.js @@ -0,0 +1,49 @@ +import T from './actionType'; +import reducer from './reducer'; +import { initialState } from './initialState'; + +describe('reducer sync state', () => { + it('updates sync state only for target graph', () => { + const state = { + ...initialState, + graphs: [ + { + graphID: 'g1', + syncStatus: { + state: 'dirty', + localHash: 'l1', + remoteHash: '', + lastResult: '', + reason: '', + }, + }, + { + graphID: 'g2', + syncStatus: { + state: 'synced', + localHash: 'l2', + remoteHash: 'r2', + lastResult: 'ok', + reason: '', + }, + }, + ], + }; + + const next = reducer(state, { + type: T.SET_GRAPH_SYNC_STATE, + payload: { + graphID: 'g1', + syncStatus: { + state: 'conflict', + remoteHash: 'remote-x', + }, + }, + }); + + expect(next.graphs[0].syncStatus.state).toBe('conflict'); + expect(next.graphs[0].syncStatus.localHash).toBe('l1'); + expect(next.graphs[0].syncStatus.remoteHash).toBe('remote-x'); + expect(next.graphs[1].syncStatus).toEqual(state.graphs[1].syncStatus); + }); +}); diff --git a/src/serverCon/crud_http.js b/src/serverCon/crud_http.js index 00df3a7..5ea2b5c 100644 --- a/src/serverCon/crud_http.js +++ b/src/serverCon/crud_http.js @@ -3,7 +3,22 @@ import ec from './config'; function readTextOrThrow(x) { return x.text().then((text) => { if (x.ok) return text; - throw new Error(text || `Request failed with status ${x.status}`); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + const err = new Error( + (data && data.message) + || text + || `Request failed with status ${x.status}`, + ); + err.status = x.status; + err.body = text; + err.data = data; + err.code = data && data.code ? data.code : null; + throw err; }); } diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 0000000..140fb2b --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1,5 @@ +window.matchMedia = window.matchMedia || (() => ({ + matches: false, + addListener: () => {}, + removeListener: () => {}, +}));