Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions server/controller/workflow.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
21 changes: 20 additions & 1 deletion server/tests/test_workflow_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -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()
2 changes: 2 additions & 0 deletions src/GraphWorkspace.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -129,6 +130,7 @@ const GraphComp = (props) => {
))}
<SearchPanel superState={superState} dispatcher={dispatcher} />
<ZoomComp dispatcher={dispatcher} superState={superState} />
<SyncStatusPanel superState={superState} />
</div>
<ConfirmModal
isOpen={superState.confirmModal.open}
Expand Down
82 changes: 82 additions & 0 deletions src/component/SyncStatusPanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import './syncStatusPanel.css';

const stateLabel = {
synced: 'Synced',
dirty: 'Dirty',
conflict: 'Conflict',
syncing: 'Syncing',
error: 'Error',
};

const SyncStatusPanel = ({ superState }) => {
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 (
<div className="sync-status-panel">
<div className="sync-status-header">
<strong>Sync</strong>
<span className={`sync-status-badge ${state}`}>{stateLabel[state] || state}</span>
</div>
<div className="sync-status-meta">
<div>
<span className="sync-key">Local:</span>
<span className="sync-value">{localHash || '-'}</span>
</div>
<div>
<span className="sync-key">Remote:</span>
<span className="sync-value">{remoteHash || '-'}</span>
</div>
</div>
<div className="sync-status-result">{lastResult}</div>
{isConflict && (
<div className="sync-status-actions">
<button
type="button"
className="confirm-btn"
onClick={() => instance && instance.forcePullFromServer()}
>
Pull remote
</button>
<button
type="button"
className="confirm-btn"
onClick={() => instance && instance.forcePushToServer()}
>
Force push local
</button>
<button
type="button"
className="cancel-btn"
onClick={() => instance && instance.openRemoteInNewTab()}
>
Open remote
</button>
<button
type="button"
className="cancel-btn"
onClick={() => instance && instance.cancelSyncConflict()}
>
Cancel
</button>
</div>
)}
</div>
);
};

export default SyncStatusPanel;
81 changes: 81 additions & 0 deletions src/component/syncStatusPanel.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading