This commit is contained in:
2025-06-23 09:30:04 +02:00
parent 9fc09be7de
commit 0da5d9305d
11 changed files with 444 additions and 40 deletions

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -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 ###

View File

@@ -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'))

View File

@@ -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)}")

View File

@@ -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')
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')
})

View File

@@ -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}`);

View File

@@ -3,6 +3,27 @@
{% block title %}Instances - DocuPulse{% endblock %}
{% block extra_css %}
<style>
.version-info {
font-size: 0.85rem;
}
.version-info code {
font-size: 0.8rem;
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
}
.badge-sm {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
}
.table td {
vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
{{ header(
title="Instances",
@@ -43,6 +64,7 @@
<th>Payment Plan</th>
<th>Main URL</th>
<th>Status</th>
<th>Version</th>
<th>Connection Token</th>
<th>Actions</th>
</tr>
@@ -74,6 +96,44 @@
{{ instance.status|title }}
</span>
</td>
<td>
{% if instance.deployed_version and instance.deployed_version != 'unknown' %}
<div class="version-info">
<div class="small">
<strong>Version:</strong>
<code class="text-primary">{{ instance.deployed_version }}</code>
</div>
{% if instance.latest_version and instance.latest_version != 'unknown' %}
<div class="small">
<strong>Latest:</strong>
<code class="text-secondary">{{ instance.latest_version }}</code>
</div>
{% if instance.deployed_version == instance.latest_version %}
<span class="badge bg-success badge-sm" data-bs-toggle="tooltip" title="Instance is up to date">
<i class="fas fa-check"></i> Up-to-date
</span>
{% else %}
<span class="badge bg-warning badge-sm" data-bs-toggle="tooltip" title="Instance is outdated">
<i class="fas fa-exclamation-triangle"></i> Outdated
</span>
{% endif %}
{% else %}
<span class="badge bg-secondary badge-sm" data-bs-toggle="tooltip" title="Latest version unknown">
<i class="fas fa-question"></i> Unknown
</span>
{% endif %}
{% if instance.version_checked_at %}
<div class="small text-muted">
<i class="fas fa-clock"></i> {{ instance.version_checked_at.strftime('%H:%M') }}
</div>
{% endif %}
</div>
{% else %}
<span class="badge bg-secondary" data-bs-toggle="tooltip" title="Version information not available">
<i class="fas fa-question"></i> Unknown
</span>
{% endif %}
</td>
<td>
{% if instance.connection_token %}
<span class="badge bg-success" data-bs-toggle="tooltip" title="Instance is authenticated">

6
version.txt Normal file
View File

@@ -0,0 +1,6 @@
{
"tag": "v1.2.3",
"commit": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
"branch": "main",
"deployed_at": "2024-01-15T10:30:00.000000"
}