diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index 3638571..3cc391e 100644 Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ diff --git a/migrations/versions/a1fd98e6630d_merge_heads.py b/migrations/versions/a1fd98e6630d_merge_heads.py new file mode 100644 index 0000000..86df9b6 --- /dev/null +++ b/migrations/versions/a1fd98e6630d_merge_heads.py @@ -0,0 +1,24 @@ +"""merge heads + +Revision ID: a1fd98e6630d +Revises: 761908f0cacf, add_password_reset_tokens +Create Date: 2025-06-23 09:12:50.264151 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1fd98e6630d' +down_revision = ('761908f0cacf', 'add_password_reset_tokens') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/migrations/versions/add_password_reset_tokens_table.py b/migrations/versions/add_password_reset_tokens_table.py index 7b8617e..4f34f7c 100644 --- a/migrations/versions/add_password_reset_tokens_table.py +++ b/migrations/versions/add_password_reset_tokens_table.py @@ -7,6 +7,7 @@ Create Date: 2024-01-01 12:00:00.000000 """ from alembic import op import sqlalchemy as sa +from sqlalchemy import text # revision identifiers, used by Alembic. revision = 'add_password_reset_tokens' @@ -15,19 +16,28 @@ branch_labels = None depends_on = None def upgrade(): - # Create password_reset_tokens table - op.create_table('password_reset_tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(length=100), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.Column('used', sa.Boolean(), nullable=True), - sa.Column('ip_address', sa.String(length=45), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('token') - ) + conn = op.get_bind() + result = conn.execute(text(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'password_reset_tokens' + ); + """)) + exists = result.scalar() + if not exists: + # Create password_reset_tokens table + op.create_table('password_reset_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(length=100), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('used', sa.Boolean(), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token') + ) def downgrade(): # Drop password_reset_tokens table diff --git a/migrations/versions/c94c2b2b9f2e_add_version_tracking_fields_to_instances.py b/migrations/versions/c94c2b2b9f2e_add_version_tracking_fields_to_instances.py new file mode 100644 index 0000000..e6450f6 --- /dev/null +++ b/migrations/versions/c94c2b2b9f2e_add_version_tracking_fields_to_instances.py @@ -0,0 +1,68 @@ +"""add version tracking fields to instances + +Revision ID: c94c2b2b9f2e +Revises: a1fd98e6630d +Create Date: 2025-06-23 09:15:13.092801 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c94c2b2b9f2e' +down_revision = 'a1fd98e6630d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('email_template') + op.drop_table('notification') + with op.batch_alter_table('instances', schema=None) as batch_op: + batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True)) + batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True)) + batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True)) + + with op.batch_alter_table('room_file', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_room_file_deleted_by_user'), type_='foreignkey') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('room_file', schema=None) as batch_op: + batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id']) + + with op.batch_alter_table('instances', schema=None) as batch_op: + batch_op.drop_column('version_checked_at') + batch_op.drop_column('latest_version') + batch_op.drop_column('deployed_branch') + batch_op.drop_column('deployed_version') + + op.create_table('notification', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('title', sa.VARCHAR(length=200), autoincrement=False, nullable=False), + sa.Column('message', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('type', sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column('read', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('notification_user_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('notification_pkey')) + ) + op.create_table('email_template', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('subject', sa.VARCHAR(length=200), autoincrement=False, nullable=False), + sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('email_template_pkey')), + sa.UniqueConstraint('name', name=op.f('email_template_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + # ### end Alembic commands ### diff --git a/models.py b/models.py index 453eba5..1d0c26e 100644 --- a/models.py +++ b/models.py @@ -528,6 +528,11 @@ class Instance(db.Model): status = db.Column(db.String(20), nullable=False, default='inactive') status_details = db.Column(db.Text, nullable=True) connection_token = db.Column(db.String(64), unique=True, nullable=True) + # Version tracking fields + deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag) + deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed + latest_version = db.Column(db.String(50), nullable=True) # Latest version available in Git + version_checked_at = db.Column(db.DateTime, nullable=True) # When version was last checked created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP')) updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP'), onupdate=db.text('CURRENT_TIMESTAMP')) diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index 832cd25..77feddd 100644 Binary files a/routes/__pycache__/main.cpython-313.pyc and b/routes/__pycache__/main.cpython-313.pyc differ diff --git a/routes/launch_api.py b/routes/launch_api.py index ed8daae..24894a5 100644 --- a/routes/launch_api.py +++ b/routes/launch_api.py @@ -13,6 +13,9 @@ from email.utils import formatdate from datetime import datetime import requests import base64 +from flask_wtf.csrf import CSRFProtect +from functools import wraps +import os launch_api = Blueprint('launch_api', __name__) @@ -657,6 +660,60 @@ def download_docker_compose(): if not git_settings: return jsonify({'message': 'Git settings not configured'}), 400 + # Get the current commit hash and latest tag for the branch + commit_hash = None + latest_tag = None + if git_settings['provider'] == 'gitea': + headers = { + 'Accept': 'application/json', + 'Authorization': f'token {git_settings["token"]}' + } + + # Get the latest commit for the branch + commit_response = requests.get( + f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/commits/{data["branch"]}', + headers=headers + ) + + if commit_response.status_code == 200: + commit_data = commit_response.json() + commit_hash = commit_data.get('sha') + else: + # Try token as query parameter if header auth fails + commit_response = requests.get( + f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/commits/{data["branch"]}?token={git_settings["token"]}', + headers={'Accept': 'application/json'} + ) + if commit_response.status_code == 200: + commit_data = commit_response.json() + commit_hash = commit_data.get('sha') + + # Get the latest tag + tags_response = requests.get( + f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/tags', + headers=headers + ) + + if tags_response.status_code == 200: + tags_data = tags_response.json() + if tags_data: + # Sort tags by commit date (newest first) and get the latest + sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True) + if sorted_tags: + latest_tag = sorted_tags[0].get('name') + else: + # Try token as query parameter if header auth fails + tags_response = requests.get( + f'{git_settings["url"]}/api/v1/repos/{data["repository"]}/tags?token={git_settings["token"]}', + headers={'Accept': 'application/json'} + ) + if tags_response.status_code == 200: + tags_data = tags_response.json() + if tags_data: + sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True) + if sorted_tags: + latest_tag = sorted_tags[0].get('name') + # Determine the provider and set up the appropriate API call if git_settings['provider'] == 'gitea': # For Gitea @@ -704,9 +761,53 @@ def download_docker_compose(): else: content = response.text + # Add version.txt creation to the docker-compose content + if commit_hash: + # Create version information with both tag and commit hash + version_info = { + 'tag': latest_tag or 'unknown', + 'commit': commit_hash, + 'branch': data['branch'], + 'deployed_at': datetime.utcnow().isoformat() + } + version_json = json.dumps(version_info, indent=2) + + # Add a command to create version.txt with the version information + version_command = f'echo \'{version_json}\' > /app/version.txt' + + # Find the web service and add the command + if 'web:' in content: + # Add the command to create version.txt before the main command + lines = content.split('\n') + new_lines = [] + in_web_service = False + command_added = False + + for line in lines: + new_lines.append(line) + + if line.strip() == 'web:': + in_web_service = True + elif in_web_service and line.strip().startswith('command:'): + # Add the version.txt creation command before the main command + new_lines.append(f' - sh -c "{version_command} && {line.split("command:")[1].strip()}"') + command_added = True + continue + elif in_web_service and line.strip() and not line.startswith(' ') and not line.startswith('#'): + # We've left the web service section + if not command_added: + # If no command was found, add a new command section + new_lines.append(f' command: sh -c "{version_command} && python app.py"') + command_added = True + in_web_service = False + + content = '\n'.join(new_lines) + return jsonify({ 'success': True, - 'content': content + 'content': content, + 'commit_hash': commit_hash, + 'latest_tag': latest_tag }) else: return jsonify({ @@ -981,26 +1082,77 @@ def save_instance(): missing_fields = [field for field in required_fields if field not in data] return jsonify({'error': f'Missing required fields: {", ".join(missing_fields)}'}), 400 - # Save instance data - instance_data = { - 'name': data['name'], - 'port': data['port'], - 'domains': data['domains'], - 'stack_id': data['stack_id'], - 'stack_name': data['stack_name'], - 'status': data['status'], - 'repository': data['repository'], - 'branch': data['branch'], - 'created_at': datetime.utcnow().isoformat() - } - - # Save to database using KeyValueSettings - KeyValueSettings.set_value(f'instance_{data["name"]}', instance_data) - - return jsonify({ - 'message': 'Instance data saved successfully', - 'data': instance_data - }) + # Check if instance already exists + existing_instance = Instance.query.filter_by(name=data['name']).first() + + if existing_instance: + # Update existing instance + existing_instance.port = data['port'] + existing_instance.domains = data['domains'] + existing_instance.stack_id = data['stack_id'] + existing_instance.stack_name = data['stack_name'] + existing_instance.status = data['status'] + existing_instance.repository = data['repository'] + existing_instance.branch = data['branch'] + existing_instance.deployed_version = data.get('deployed_version', 'unknown') + existing_instance.deployed_branch = data.get('deployed_branch', data['branch']) + existing_instance.version_checked_at = datetime.utcnow() + + db.session.commit() + + return jsonify({ + 'message': 'Instance data updated successfully', + 'data': { + 'name': existing_instance.name, + 'port': existing_instance.port, + 'domains': existing_instance.domains, + 'stack_id': existing_instance.stack_id, + 'stack_name': existing_instance.stack_name, + 'status': existing_instance.status, + 'repository': existing_instance.repository, + 'branch': existing_instance.branch, + 'deployed_version': existing_instance.deployed_version, + 'deployed_branch': existing_instance.deployed_branch + } + }) + else: + # Create new instance + instance = Instance( + name=data['name'], + company='Loading...', # Will be updated later + rooms_count=0, + conversations_count=0, + data_size=0.0, + payment_plan='Basic', + main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}", + status=data['status'], + port=data['port'], + stack_id=data['stack_id'], + stack_name=data['stack_name'], + repository=data['repository'], + branch=data['branch'], + deployed_version=data.get('deployed_version', 'unknown'), + deployed_branch=data.get('deployed_branch', data['branch']) + ) + + db.session.add(instance) + db.session.commit() + + return jsonify({ + 'message': 'Instance data saved successfully', + 'data': { + 'name': instance.name, + 'port': instance.port, + 'domains': instance.domains, + 'stack_id': instance.stack_id, + 'stack_name': instance.stack_name, + 'status': instance.status, + 'repository': instance.repository, + 'branch': instance.branch, + 'deployed_version': instance.deployed_version, + 'deployed_branch': instance.deployed_branch + } + }) except Exception as e: current_app.logger.error(f"Error saving instance data: {str(e)}") diff --git a/routes/main.py b/routes/main.py index 846afb4..ba0562f 100644 --- a/routes/main.py +++ b/routes/main.py @@ -379,18 +379,58 @@ def init_routes(main_bp): instances = Instance.query.order_by(Instance.name.asc()).all() - # Check status for each instance + # Get Git settings + git_settings = KeyValueSettings.get_value('git_settings') + gitea_url = git_settings.get('url') if git_settings else None + gitea_token = git_settings.get('token') if git_settings else None + gitea_repo = git_settings.get('repo') if git_settings else None + for instance in instances: + # 1. Check status status_info = check_instance_status(instance) instance.status = status_info['status'] instance.status_details = status_info['details'] + + # 2. Check deployed version + deployed_version = None + deployed_tag = None + deployed_commit = None + try: + version_url = f"{instance.main_url.rstrip('/')}/api/version" + resp = requests.get(version_url, timeout=5) + if resp.status_code == 200: + version_data = resp.json() + deployed_version = version_data.get('version', 'unknown') + deployed_tag = version_data.get('tag', 'unknown') + deployed_commit = version_data.get('commit', 'unknown') + except Exception as e: + deployed_version = None + deployed_tag = None + deployed_commit = None + + instance.deployed_version = deployed_tag or deployed_version or 'unknown' + instance.deployed_branch = instance.deployed_branch or 'master' + + # 3. Check latest version from Gitea (if settings available) + latest_version = None + deployed_branch = instance.deployed_branch or 'master' + if gitea_url and gitea_token and gitea_repo: + try: + headers = {'Accept': 'application/json', 'Authorization': f'token {gitea_token}'} + # Gitea API: /api/v1/repos/{owner}/{repo}/commits/{branch} + commit_url = f"{gitea_url}/api/v1/repos/{gitea_repo}/commits/{deployed_branch}" + commit_resp = requests.get(commit_url, headers=headers, timeout=5) + if commit_resp.status_code == 200: + latest_version = commit_resp.json().get('sha') + except Exception as e: + latest_version = None + instance.latest_version = latest_version or 'unknown' + instance.version_checked_at = datetime.utcnow() db.session.commit() - # Get connection settings portainer_settings = KeyValueSettings.get_value('portainer_settings') nginx_settings = KeyValueSettings.get_value('nginx_settings') - git_settings = KeyValueSettings.get_value('git_settings') cloudflare_settings = KeyValueSettings.get_value('cloudflare_settings') return render_template('main/instances.html', @@ -1975,4 +2015,36 @@ def init_routes(main_bp): flash('This page is only available in master instances.', 'error') return redirect(url_for('main.dashboard')) - return render_template('wiki/base.html') \ No newline at end of file + return render_template('wiki/base.html') + + @main_bp.route('/api/version') + def api_version(): + version_file = os.path.join(current_app.root_path, 'version.txt') + version = 'unknown' + version_data = {} + + if os.path.exists(version_file): + with open(version_file, 'r') as f: + content = f.read().strip() + + # Try to parse as JSON first (new format) + try: + version_data = json.loads(content) + version = version_data.get('tag', 'unknown') + except json.JSONDecodeError: + # Fallback to old format (just commit hash) + version = content + version_data = { + 'tag': 'unknown', + 'commit': content, + 'branch': 'unknown', + 'deployed_at': 'unknown' + } + + return jsonify({ + 'version': version, + 'tag': version_data.get('tag', 'unknown'), + 'commit': version_data.get('commit', 'unknown'), + 'branch': version_data.get('branch', 'unknown'), + 'deployed_at': version_data.get('deployed_at', 'unknown') + }) \ No newline at end of file diff --git a/static/js/launch_progress.js b/static/js/launch_progress.js index 35ad2a6..fa2892a 100644 --- a/static/js/launch_progress.js +++ b/static/js/launch_progress.js @@ -546,12 +546,19 @@ async function startLaunch(data) { stack_name: stackResult.data.name, status: stackResult.data.status, repository: data.repository, - branch: data.branch + branch: data.branch, + deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown', + deployed_branch: data.branch }; console.log('Saving instance data:', instanceData); const saveResult = await saveInstanceData(instanceData); console.log('Save result:', saveResult); - await updateStep(10, 'Saving Instance Data', 'Instance data saved successfully'); + + // Update step with version information + const versionInfo = dockerComposeResult.commit_hash ? + `Instance data saved successfully. Version: ${dockerComposeResult.commit_hash.substring(0, 8)}` : + 'Instance data saved successfully'; + await updateStep(10, 'Saving Instance Data', versionInfo); } catch (error) { console.error('Error saving instance data:', error); await updateStep(10, 'Saving Instance Data', `Error: ${error.message}`); diff --git a/templates/main/instances.html b/templates/main/instances.html index 9cc2a95..29d9202 100644 --- a/templates/main/instances.html +++ b/templates/main/instances.html @@ -3,6 +3,27 @@ {% block title %}Instances - DocuPulse{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %} {{ header( title="Instances", @@ -43,6 +64,7 @@ Payment Plan Main URL Status + Version Connection Token Actions @@ -74,6 +96,44 @@ {{ instance.status|title }} + + {% if instance.deployed_version and instance.deployed_version != 'unknown' %} +
+
+ Version: + {{ instance.deployed_version }} +
+ {% if instance.latest_version and instance.latest_version != 'unknown' %} +
+ Latest: + {{ instance.latest_version }} +
+ {% if instance.deployed_version == instance.latest_version %} + + Up-to-date + + {% else %} + + Outdated + + {% endif %} + {% else %} + + Unknown + + {% endif %} + {% if instance.version_checked_at %} +
+ {{ instance.version_checked_at.strftime('%H:%M') }} +
+ {% endif %} +
+ {% else %} + + Unknown + + {% endif %} + {% if instance.connection_token %} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..e1fa3e4 --- /dev/null +++ b/version.txt @@ -0,0 +1,6 @@ +{ + "tag": "v1.2.3", + "commit": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + "branch": "main", + "deployed_at": "2024-01-15T10:30:00.000000" +} \ No newline at end of file