Skip to content

Commit eccddfd

Browse files
authored
Merge pull request #391 from avinxshKD/feat/explicit-sync-state-panel
Add explicit sync state and persistent sync panel for conflict recovery
2 parents 96755da + fabecb6 commit eccddfd

12 files changed

Lines changed: 444 additions & 28 deletions

File tree

server/controller/workflow.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from model.workflows import WorkFlowModel
2-
from flask import request, make_response, Blueprint
2+
from flask import request, make_response, Blueprint, jsonify
33
import defusedxml.ElementTree as ET
44

55
workFlow = Blueprint('workflow', __name__)
@@ -26,6 +26,15 @@ def getAllActionHash(root):
2626
return list(map(lambda ah: ah.find(f'{{{xmlns}}}hash').text, root.find(f'{{{xmlns}}}graph').findall(f'{{{xmlns}}}actionHistory')))
2727

2828

29+
def syncConflict(message='Different History'):
30+
return jsonify({'code': 'SYNC_CONFLICT', 'message': message}), 400
31+
32+
33+
def isConflictMessage(message):
34+
msg = (message or '').lower()
35+
return 'latest changes' in msg or 'different history' in msg
36+
37+
2938
@workFlow.route("/", methods=['POST'])
3039
def postWorkflow():
3140
try:
@@ -45,7 +54,7 @@ def getWorkflow(serverID):
4554
latestHash = request.headers['X-Latest-Hash']
4655
allHash = getAllActionHash(ET.fromstring(graphml))
4756
if(latestHash not in allHash):
48-
return 'Different History', 400
57+
return syncConflict()
4958
r = make_response(graphml)
5059
r.headers.set('Content-Type', "application/xml")
5160
return r
@@ -67,4 +76,8 @@ def updateWorkflow(serverID):
6776
res = workFlowModel.forceUpdate(serverID, graphML, latestHash)
6877
else:
6978
res = workFlowModel.update(serverID, graphML, latestHash, allHash)
70-
return res[1], 200 if res[0] else 400
79+
if res[0]:
80+
return res[1], 200
81+
if isConflictMessage(res[1]):
82+
return syncConflict(res[1])
83+
return res[1], 400

server/tests/test_workflow_controller.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ def forceUpdate(self, serverID, graphml, latestHash):
3232
return (True, latestHash)
3333

3434

35+
class FakeWorkFlowModelUpdateMissing(FakeWorkFlowModel):
36+
def update(self, serverID, graphml, latestHash, allHash):
37+
return (False, 'serverID do not exists.')
38+
39+
3540
class WorkflowControllerTests(unittest.TestCase):
3641
@classmethod
3742
def setUpClass(cls):
@@ -62,6 +67,12 @@ def make_client(self, graph_response):
6267
app.register_blueprint(self.workflow_module.workFlow, url_prefix='/workflow')
6368
return app.test_client()
6469

70+
def make_client_with_model(self, model):
71+
self.workflow_module.workFlowModel = model
72+
app = Flask(__name__)
73+
app.register_blueprint(self.workflow_module.workFlow, url_prefix='/workflow')
74+
return app.test_client()
75+
6576
def test_missing_workflow_returns_404_for_none(self):
6677
client = self.make_client(None)
6778
response = client.get('/workflow/missing-id')
@@ -78,7 +89,8 @@ def test_hash_header_returns_400_for_different_history(self):
7889
client = self.make_client(VALID_GRAPHML)
7990
response = client.get('/workflow/existing-id', headers={'X-Latest-Hash': 'unknown-hash'})
8091
self.assertEqual(response.status_code, 400)
81-
self.assertEqual(response.get_data(as_text=True), 'Different History')
92+
self.assertEqual(response.get_json()['code'], 'SYNC_CONFLICT')
93+
self.assertEqual(response.get_json()['message'], 'Different History')
8294

8395
def test_hash_header_returns_200_for_matching_history(self):
8496
client = self.make_client(VALID_GRAPHML)
@@ -117,6 +129,13 @@ def test_force_update_workflow_returns_200(self):
117129
content_type='application/xml')
118130
self.assertEqual(response.status_code, 200)
119131

132+
def test_update_workflow_non_conflict_error_returns_plain_400(self):
133+
client = self.make_client_with_model(FakeWorkFlowModelUpdateMissing(None))
134+
response = client.post('/workflow/test01', data=VALID_GRAPHML,
135+
content_type='application/xml')
136+
self.assertEqual(response.status_code, 400)
137+
self.assertEqual(response.get_data(as_text=True), 'serverID do not exists.')
138+
120139

121140
if __name__ == '__main__':
122141
unittest.main()

src/GraphWorkspace.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import ZoomComp from './component/ZoomSetter';
33
import ConfirmModal from './component/modals/ConfirmModal';
4+
import SyncStatusPanel from './component/SyncStatusPanel';
45
import SearchPanel from './component/SearchPanel';
56
import { actionType as T } from './reducer';
67
import './graphWorkspace.css';
@@ -129,6 +130,7 @@ const GraphComp = (props) => {
129130
))}
130131
<SearchPanel superState={superState} dispatcher={dispatcher} />
131132
<ZoomComp dispatcher={dispatcher} superState={superState} />
133+
<SyncStatusPanel superState={superState} />
132134
</div>
133135
<ConfirmModal
134136
isOpen={superState.confirmModal.open}

src/component/SyncStatusPanel.jsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React from 'react';
2+
import './syncStatusPanel.css';
3+
4+
const stateLabel = {
5+
synced: 'Synced',
6+
dirty: 'Dirty',
7+
conflict: 'Conflict',
8+
syncing: 'Syncing',
9+
error: 'Error',
10+
};
11+
12+
const SyncStatusPanel = ({ superState }) => {
13+
if (superState.curGraphIndex === -1) return null;
14+
15+
const graph = superState.graphs[superState.curGraphIndex];
16+
if (!graph) return null;
17+
18+
const syncStatus = graph.syncStatus || {};
19+
const {
20+
state = 'dirty',
21+
localHash = '',
22+
remoteHash = '',
23+
lastResult = 'Not synced yet',
24+
} = syncStatus;
25+
26+
const instance = superState.curGraphInstance;
27+
const isConflict = state === 'conflict';
28+
29+
return (
30+
<div className="sync-status-panel">
31+
<div className="sync-status-header">
32+
<strong>Sync</strong>
33+
<span className={`sync-status-badge ${state}`}>{stateLabel[state] || state}</span>
34+
</div>
35+
<div className="sync-status-meta">
36+
<div>
37+
<span className="sync-key">Local:</span>
38+
<span className="sync-value">{localHash || '-'}</span>
39+
</div>
40+
<div>
41+
<span className="sync-key">Remote:</span>
42+
<span className="sync-value">{remoteHash || '-'}</span>
43+
</div>
44+
</div>
45+
<div className="sync-status-result">{lastResult}</div>
46+
{isConflict && (
47+
<div className="sync-status-actions">
48+
<button
49+
type="button"
50+
className="confirm-btn"
51+
onClick={() => instance && instance.forcePullFromServer()}
52+
>
53+
Pull remote
54+
</button>
55+
<button
56+
type="button"
57+
className="confirm-btn"
58+
onClick={() => instance && instance.forcePushToServer()}
59+
>
60+
Force push local
61+
</button>
62+
<button
63+
type="button"
64+
className="cancel-btn"
65+
onClick={() => instance && instance.openRemoteInNewTab()}
66+
>
67+
Open remote
68+
</button>
69+
<button
70+
type="button"
71+
className="cancel-btn"
72+
onClick={() => instance && instance.cancelSyncConflict()}
73+
>
74+
Cancel
75+
</button>
76+
</div>
77+
)}
78+
</div>
79+
);
80+
};
81+
82+
export default SyncStatusPanel;

src/component/syncStatusPanel.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
.sync-status-panel {
2+
position: absolute;
3+
right: 12px;
4+
bottom: 12px;
5+
z-index: 5;
6+
width: 320px;
7+
background: var(--bg-secondary);
8+
color: var(--text-primary);
9+
border: 1px solid var(--border-primary);
10+
border-radius: 8px;
11+
padding: 10px;
12+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
13+
font-size: 12px;
14+
}
15+
16+
.sync-status-header {
17+
display: flex;
18+
justify-content: space-between;
19+
align-items: center;
20+
margin-bottom: 8px;
21+
}
22+
23+
.sync-status-badge {
24+
font-size: 11px;
25+
border-radius: 10px;
26+
padding: 2px 8px;
27+
background: #666;
28+
}
29+
30+
.sync-status-badge.synced {
31+
background: #2e7d32;
32+
}
33+
34+
.sync-status-badge.dirty {
35+
background: #ef6c00;
36+
}
37+
38+
.sync-status-badge.conflict {
39+
background: #c62828;
40+
}
41+
42+
.sync-status-badge.syncing {
43+
background: #1565c0;
44+
}
45+
46+
.sync-status-badge.error {
47+
background: #7b1fa2;
48+
}
49+
50+
.sync-status-meta {
51+
display: grid;
52+
grid-template-columns: 1fr;
53+
row-gap: 4px;
54+
margin-bottom: 8px;
55+
}
56+
57+
.sync-key {
58+
color: var(--text-primary);
59+
opacity: 0.75;
60+
margin-right: 4px;
61+
}
62+
63+
.sync-value {
64+
word-break: break-all;
65+
}
66+
67+
.sync-status-result {
68+
margin-bottom: 10px;
69+
}
70+
71+
.sync-status-actions {
72+
display: flex;
73+
flex-wrap: wrap;
74+
gap: 6px;
75+
}
76+
77+
.sync-status-actions .confirm-btn,
78+
.sync-status-actions .cancel-btn {
79+
padding: 6px 8px;
80+
font-size: 12px;
81+
}

0 commit comments

Comments
 (0)