From a7dfc28a8b72e94371166274200e1fb539d897fa Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 30 Nov 2025 19:50:29 +1100 Subject: [PATCH] Add version history for shared environments --- routes/shared_env.py | 82 +++++++++++++++++++ static/js/mithril/sharedEnvironments.js | 103 +++++++++++++++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/routes/shared_env.py b/routes/shared_env.py index f3a273f..daca6dc 100644 --- a/routes/shared_env.py +++ b/routes/shared_env.py @@ -415,3 +415,85 @@ def unlink_function(env_id): except Exception as e: return jsonify({'status': 'error', 'message': str(e)}), 500 +@shared_env.route('//history', methods=['GET']) +@login_required +def history(env_id): + """Get version history for a shared environment""" + try: + # Verify ownership + existing = db.execute( + 'SELECT id, name FROM shared_environments WHERE id=%s AND user_id=%s', + [env_id, current_user.id], + one=True + ) + + if not existing: + return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 + + # Fetch all versions + versions = db.execute(''' + SELECT version_number, environment, versioned_at + FROM shared_environment_versions + WHERE shared_env_id = %s + ORDER BY version_number DESC + ''', [env_id]) + + # Convert datetime objects to ISO format strings + for version in versions or []: + version['versioned_at'] = version['versioned_at'].isoformat() if version.get('versioned_at') else None + + return jsonify({ + 'status': 'success', + 'env_name': existing['name'], + 'versions': versions if versions else [] + }) + + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@shared_env.route('//restore', methods=['POST']) +@login_required +def restore(env_id): + """Restore a shared environment to a previous version""" + try: + version_number = request.json.get('version_number') + + if not version_number: + return jsonify({'status': 'error', 'message': 'Version number is required'}), 400 + + # Verify ownership + existing = db.execute( + 'SELECT id, name FROM shared_environments WHERE id=%s AND user_id=%s', + [env_id, current_user.id], + one=True + ) + + if not existing: + return jsonify({'status': 'error', 'message': 'Shared environment not found'}), 404 + + # Fetch the selected version's environment data + version_data = db.execute( + 'SELECT environment FROM shared_environment_versions WHERE shared_env_id=%s AND version_number=%s', + [env_id, version_number], + one=True + ) + + if not version_data: + return jsonify({'status': 'error', 'message': 'Version not found'}), 404 + + # Update the shared environment with the old version's data + # This will trigger the versioning function to create a new version + db.execute( + 'UPDATE shared_environments SET environment=%s, updated_at=NOW() WHERE id=%s', + [json.dumps(version_data['environment']), env_id], + commit=True + ) + + return jsonify({ + 'status': 'success', + 'message': f'Restored to version {version_number}' + }) + + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + diff --git a/static/js/mithril/sharedEnvironments.js b/static/js/mithril/sharedEnvironments.js index cd9986e..7a47ce1 100644 --- a/static/js/mithril/sharedEnvironments.js +++ b/static/js/mithril/sharedEnvironments.js @@ -20,6 +20,12 @@ const SharedEnvironments = { selectedHttp: '', selectedTimer: '' }, + historyModal: { + isOpen: false, + envId: null, + envName: '', + versions: [] + }, expandedEnvs: new Set(), oninit: function(vnode) { @@ -240,6 +246,46 @@ const SharedEnvironments = { } }, + openHistoryModal: async function(envId, envName) { + try { + const response = await m.request({ + method: 'GET', + url: `/shared_env/${envId}/history` + }); + SharedEnvironments.historyModal = { + isOpen: true, + envId: envId, + envName: envName, + versions: response.versions || [] + }; + } catch (err) { + alert('Error loading version history: ' + err.message); + } + }, + + closeHistoryModal: function() { + SharedEnvironments.historyModal.isOpen = false; + }, + + restoreVersion: async function(versionNumber) { + if (!confirm(`Restore to version ${versionNumber}?\n\nThis will update the environment to match version ${versionNumber} (creating a new version).`)) { + return; + } + + try { + await m.request({ + method: 'POST', + url: `/shared_env/${SharedEnvironments.historyModal.envId}/restore`, + body: { version_number: versionNumber } + }); + SharedEnvironments.closeHistoryModal(); + await SharedEnvironments.loadEnvironments(); + alert('Version restored successfully!'); + } catch (err) { + alert('Error: ' + (err.message || 'Failed to restore')); + } + }, + view: function() { return m("div.container.mx-auto.p-6.max-w-6xl", [ // Header @@ -271,7 +317,10 @@ const SharedEnvironments = { SharedEnvironments.modal.isOpen ? SharedEnvironments.renderModal() : null, // Link Modal - SharedEnvironments.linkModal.isOpen ? SharedEnvironments.renderLinkModal() : null + SharedEnvironments.linkModal.isOpen ? SharedEnvironments.renderLinkModal() : null, + + // History Modal + SharedEnvironments.historyModal.isOpen ? SharedEnvironments.renderHistoryModal() : null ]); }, @@ -321,6 +370,12 @@ const SharedEnvironments = { ]) ]), m("div.flex.space-x-2.ml-4", [ + m("button.text-purple-600.hover:text-purple-800.dark:text-purple-400.dark:hover:text-purple-300.p-2[title=History]", + { onclick: () => SharedEnvironments.openHistoryModal(env.id, env.name) }, + m("svg.w-5.h-5[fill=none][stroke=currentColor][viewBox=0 0 24 24]", + m("path[stroke-linecap=round][stroke-linejoin=round][stroke-width=2][d=M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z]") + ) + ), m("button.text-blue-600.hover:text-blue-800.dark:text-blue-400.dark:hover:text-blue-300.p-2[title=Edit]", { onclick: () => SharedEnvironments.openEditModal(env) }, m("svg.w-5.h-5[fill=none][stroke=currentColor][viewBox=0 0 24 24]", @@ -530,5 +585,51 @@ const SharedEnvironments = { ]) ]) ); + }, + + renderHistoryModal: function() { + return m("div.fixed.inset-0.bg-gray-600.bg-opacity-50.overflow-y-auto.h-full.w-full.z-50", + { onclick: (e) => e.target === e.currentTarget && SharedEnvironments.closeHistoryModal() }, + m("div.relative.top-20.mx-auto.p-5.border.w-full.max-w-3xl.shadow-lg.rounded-md.bg-white.dark:bg-gray-800", [ + m("div.flex.items-center.justify-between.mb-4", [ + m("h3.text-lg.font-medium.text-gray-900.dark:text-white", [ + "Version History: ", + m("span.text-purple-600.dark:text-purple-400", SharedEnvironments.historyModal.envName) + ]), + m("button.text-gray-400.hover:text-gray-600.dark:hover:text-gray-300", + { onclick: () => SharedEnvironments.closeHistoryModal() }, + m("svg.w-6.h-6[fill=none][stroke=currentColor][viewBox=0 0 24 24]", + m("path[stroke-linecap=round][stroke-linejoin=round][stroke-width=2][d=M6 18L18 6M6 6l12 12]") + ) + ) + ]), + + SharedEnvironments.historyModal.versions.length > 0 + ? m("div.space-y-3.max-h-96.overflow-y-auto", + SharedEnvironments.historyModal.versions.map(version => + m("div.border.border-gray-300.dark:border-gray-600.rounded-lg.p-4.hover:bg-gray-50.dark:hover:bg-gray-700.transition-colors", [ + m("div.flex.items-center.justify-between.mb-2", [ + m("div.flex.items-center.space-x-2", [ + m("span.text-sm.font-semibold.text-purple-600.dark:text-purple-400", `Version ${version.version_number}`), + m("span.text-xs.text-gray-500.dark:text-gray-400", + new Date(version.versioned_at).toLocaleString() + ) + ]), + m("button.px-3.py-1.text-sm.bg-purple-600.hover:bg-purple-700.text-white.rounded-md", + { onclick: () => SharedEnvironments.restoreVersion(version.version_number) }, + "Restore" + ) + ]), + m("div.bg-gray-100.dark:bg-gray-900.rounded.p-3.font-mono.text-xs.overflow-x-auto", + m("pre.text-gray-800.dark:text-gray-200", + JSON.stringify(version.environment, null, 2) + ) + ) + ]) + ) + ) + : m("div.text-center.py-8.text-gray-500.dark:text-gray-400", "No version history available") + ]) + ); } };