+
+ 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: () => {},
+}));