12 Commits
0.9 ... 0.10.2

Author SHA1 Message Date
f5168c27bf Update instances.html 2025-06-23 19:06:08 +02:00
4cf9cca116 version display on instances page 2025-06-23 15:46:29 +02:00
af375a2b5c version v3 2025-06-23 15:17:17 +02:00
23a55e025c Update launch_progress.js 2025-06-23 15:05:07 +02:00
40b1a63cf5 much improved launch process 2025-06-23 14:50:37 +02:00
033f82eb2b better 504 handling 2025-06-23 14:24:13 +02:00
1370bef1f1 version v2 2025-06-23 14:11:11 +02:00
1a6741ec10 remove versions for now 2025-06-23 11:06:54 +02:00
0b9005b481 Update launch_progress.js 2025-06-23 10:56:58 +02:00
7ec3027410 Update entrypoint.sh 2025-06-23 09:46:21 +02:00
405cc83ba1 Update launch_progress.js 2025-06-23 09:44:03 +02:00
0bbdf0eaab better timeouts 2025-06-23 09:35:15 +02:00
15 changed files with 1514 additions and 535 deletions

View File

@@ -10,8 +10,9 @@ DocuPulse is a powerful document management system designed to streamline docume
### Prerequisites ### Prerequisites
- Node.js (version 18 or higher) - Python 3.11 or higher
- npm or yarn - PostgreSQL 13 or higher
- Docker and Docker Compose (for containerized deployment)
### Installation ### Installation
@@ -23,18 +24,50 @@ cd docupulse
2. Install dependencies: 2. Install dependencies:
```bash ```bash
npm install pip install -r requirements.txt
# or
yarn install
``` ```
3. Start the development server: 3. Set up environment variables:
```bash ```bash
npm run dev # Copy example environment file
# or cp .env.example .env
yarn dev
# Set version information for local development
python set_version.py
``` ```
4. Initialize the database:
```bash
flask db upgrade
flask create-admin
```
5. Start the development server:
```bash
python app.py
```
## Version Tracking
DocuPulse uses a database-only approach for version tracking:
- **Environment Variables**: Version information is passed via environment variables (`APP_VERSION`, `GIT_COMMIT`, `GIT_BRANCH`, `DEPLOYED_AT`)
- **Database Storage**: Instance version information is stored in the `instances` table
- **API Endpoint**: Version information is available via `/api/version`
### Setting Version Information
For local development:
```bash
python set_version.py
```
For production deployments, set the following environment variables:
- `APP_VERSION`: Application version/tag
- `GIT_COMMIT`: Git commit hash
- `GIT_BRANCH`: Git branch name
- `DEPLOYED_AT`: Deployment timestamp
## Features ## Features
- Document upload and management - Document upload and management
@@ -42,6 +75,8 @@ yarn dev
- Secure document storage - Secure document storage
- User authentication and authorization - User authentication and authorization
- Document version control - Document version control
- Multi-tenant instance management
- RESTful API
## Contributing ## Contributing

4
app.py
View File

@@ -37,6 +37,10 @@ def create_app():
app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000') app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000')
app.config['PREFERRED_URL_SCHEME'] = os.getenv('PREFERRED_URL_SCHEME', 'http') app.config['PREFERRED_URL_SCHEME'] = os.getenv('PREFERRED_URL_SCHEME', 'http')
# Configure request timeouts for long-running operations
app.config['REQUEST_TIMEOUT'] = int(os.getenv('REQUEST_TIMEOUT', '300')) # 5 minutes default
app.config['STACK_DEPLOYMENT_TIMEOUT'] = int(os.getenv('STACK_DEPLOYMENT_TIMEOUT', '300')) # 5 minutes for stack deployment
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
migrate = Migrate(app, db) migrate = Migrate(app, db)

View File

@@ -21,6 +21,10 @@ services:
- POSTGRES_PASSWORD=docupulse_${PORT:-10335} - POSTGRES_PASSWORD=docupulse_${PORT:-10335}
- POSTGRES_DB=docupulse_${PORT:-10335} - POSTGRES_DB=docupulse_${PORT:-10335}
- MASTER=${ISMASTER:-false} - MASTER=${ISMASTER:-false}
- APP_VERSION=${APP_VERSION:-unknown}
- GIT_COMMIT=${GIT_COMMIT:-unknown}
- GIT_BRANCH=${GIT_BRANCH:-unknown}
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
volumes: volumes:
- docupulse_uploads:/app/uploads - docupulse_uploads:/app/uploads
depends_on: depends_on:

View File

@@ -71,8 +71,25 @@ with app.app_context():
# Create admin user if it doesn't exist # Create admin user if it doesn't exist
print('Creating admin user...') print('Creating admin user...')
try: try:
admin = User.query.filter_by(email='administrator@docupulse.com').first() # Check for admin user by both username and email to avoid constraint violations
if not admin: admin_by_username = User.query.filter_by(username='administrator').first()
admin_by_email = User.query.filter_by(email='administrator@docupulse.com').first()
if admin_by_username and admin_by_email and admin_by_username.id == admin_by_email.id:
print('Admin user already exists (found by both username and email).')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
elif admin_by_username or admin_by_email:
print('WARNING: Found partial admin user data:')
if admin_by_username:
print(f' - Found user with username "administrator" (ID: {admin_by_username.id})')
if admin_by_email:
print(f' - Found user with email "administrator@docupulse.com" (ID: {admin_by_email.id})')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
else:
print('Admin user not found, creating new admin user...') print('Admin user not found, creating new admin user...')
admin = User( admin = User(
username='administrator', username='administrator',
@@ -93,15 +110,26 @@ with app.app_context():
print('Admin credentials:') print('Admin credentials:')
print('Email: administrator@docupulse.com') print('Email: administrator@docupulse.com')
print('Password: changeme') print('Password: changeme')
except Exception as e: except Exception as commit_error:
db.session.rollback() db.session.rollback()
log_error('Failed to commit admin user creation', e) if 'duplicate key value violates unique constraint' in str(commit_error):
raise print('WARNING: Admin user creation failed due to duplicate key constraint.')
else: print('This might indicate a race condition or the user was created by another process.')
print('Admin user already exists.') print('Checking for existing admin user again...')
print('Admin credentials:') # Check again after the failed commit
print('Email: administrator@docupulse.com') admin_by_username = User.query.filter_by(username='administrator').first()
print('Password: changeme') admin_by_email = User.query.filter_by(email='administrator@docupulse.com').first()
if admin_by_username or admin_by_email:
print('Admin user now exists (likely created by another process).')
print('Admin credentials:')
print('Email: administrator@docupulse.com')
print('Password: changeme')
else:
log_error('Admin user creation failed and user still not found', commit_error)
raise
else:
log_error('Failed to commit admin user creation', commit_error)
raise
except Exception as e: except Exception as e:
log_error('Error during admin user creation/check', e) log_error('Error during admin user creation/check', e)
raise raise

View File

@@ -564,3 +564,30 @@ def generate_password_reset_token(current_user, user_id):
'expires_at': reset_token.expires_at.isoformat(), 'expires_at': reset_token.expires_at.isoformat(),
'user_email': user.email 'user_email': user.email
}) })
# Version Information
@admin_api.route('/version-info', methods=['GET'])
@csrf.exempt
@token_required
def get_version_info(current_user):
"""Get version information from environment variables"""
try:
version_info = {
'app_version': os.environ.get('APP_VERSION', 'unknown'),
'git_commit': os.environ.get('GIT_COMMIT', 'unknown'),
'git_branch': os.environ.get('GIT_BRANCH', 'unknown'),
'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'),
'ismaster': os.environ.get('ISMASTER', 'false'),
'port': os.environ.get('PORT', 'unknown')
}
return jsonify(version_info)
except Exception as e:
current_app.logger.error(f"Error getting version info: {str(e)}")
return jsonify({
'error': str(e),
'app_version': 'unknown',
'git_commit': 'unknown',
'git_branch': 'unknown',
'deployed_at': 'unknown'
}), 500

View File

@@ -214,7 +214,6 @@ def list_gitea_repos():
return jsonify({'message': 'Missing required fields'}), 400 return jsonify({'message': 'Missing required fields'}), 400
try: try:
# Try different authentication methods
headers = { headers = {
'Accept': 'application/json' 'Accept': 'application/json'
} }
@@ -761,48 +760,6 @@ def download_docker_compose():
else: else:
content = response.text 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({ return jsonify({
'success': True, 'success': True,
'content': content, 'content': content,
@@ -831,6 +788,9 @@ def deploy_stack():
if not portainer_settings: if not portainer_settings:
return jsonify({'error': 'Portainer settings not configured'}), 400 return jsonify({'error': 'Portainer settings not configured'}), 400
# Define timeout early to ensure it's available throughout the function
stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes
# Verify Portainer authentication # Verify Portainer authentication
auth_response = requests.get( auth_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/status", f"{portainer_settings['url'].rstrip('/')}/api/status",
@@ -872,6 +832,7 @@ def deploy_stack():
# Log the request data # Log the request data
current_app.logger.info(f"Creating stack with data: {json.dumps(data)}") current_app.logger.info(f"Creating stack with data: {json.dumps(data)}")
current_app.logger.info(f"Using endpoint ID: {endpoint_id}") current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
current_app.logger.info(f"Using timeout: {stack_timeout} seconds")
# First, check if a stack with this name already exists # First, check if a stack with this name already exists
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks" stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
@@ -891,9 +852,12 @@ def deploy_stack():
if stack['Name'] == data['name']: if stack['Name'] == data['name']:
current_app.logger.info(f"Found existing stack: {stack['Name']} (ID: {stack['Id']})") current_app.logger.info(f"Found existing stack: {stack['Name']} (ID: {stack['Id']})")
return jsonify({ return jsonify({
'name': stack['Name'], 'success': True,
'id': stack['Id'], 'data': {
'status': 'existing' 'name': stack['Name'],
'id': stack['Id'],
'status': 'existing'
}
}) })
# If no existing stack found, proceed with creation # If no existing stack found, proceed with creation
@@ -906,7 +870,7 @@ def deploy_stack():
# Add endpointId as a query parameter # Add endpointId as a query parameter
params = {'endpointId': endpoint_id} params = {'endpointId': endpoint_id}
# Set a longer timeout for stack creation (10 minutes) # Use a configurable timeout for stack creation initiation
create_response = requests.post( create_response = requests.post(
url, url,
headers={ headers={
@@ -916,7 +880,7 @@ def deploy_stack():
}, },
params=params, params=params,
json=request_body, json=request_body,
timeout=600 # 10 minutes timeout for stack creation timeout=stack_timeout # Use configurable timeout
) )
# Log the response details # Log the response details
@@ -936,15 +900,26 @@ def deploy_stack():
return jsonify({'error': f'Failed to create stack: {error_message}'}), 500 return jsonify({'error': f'Failed to create stack: {error_message}'}), 500
stack_info = create_response.json() stack_info = create_response.json()
current_app.logger.info(f"Stack creation initiated: {stack_info['Name']} (ID: {stack_info['Id']})")
return jsonify({ return jsonify({
'name': stack_info['Name'], 'success': True,
'id': stack_info['Id'], 'data': {
'status': 'created' 'name': stack_info['Name'],
'id': stack_info['Id'],
'status': 'creating'
}
}) })
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
current_app.logger.error("Request timed out while deploying stack") current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack deployment")
return jsonify({'error': 'Request timed out while deploying stack. The operation may still be in progress.'}), 504 current_app.logger.error(f"Stack name: {data.get('name', 'unknown') if 'data' in locals() else 'unknown'}")
current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}")
return jsonify({
'error': f'Request timed out after {stack_timeout} seconds while initiating stack deployment. The operation may still be in progress.',
'timeout_seconds': stack_timeout,
'stack_name': data.get('name', 'unknown') if 'data' in locals() else 'unknown'
}), 504
except Exception as e: except Exception as e:
current_app.logger.error(f"Error deploying stack: {str(e)}") current_app.logger.error(f"Error deploying stack: {str(e)}")
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@@ -1009,48 +984,74 @@ def check_stack_status():
# Get stack services to check their status # Get stack services to check their status
services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services" services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services"
services_response = requests.get( current_app.logger.info(f"Checking services for stack {data['stack_name']} at endpoint {endpoint_id}")
services_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={data["stack_name"]}'})},
timeout=30
)
if not services_response.ok: try:
return jsonify({'error': 'Failed to get stack services'}), 500 services_response = requests.get(
services_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={data["stack_name"]}'})},
timeout=30
)
services = services_response.json() current_app.logger.info(f"Services API response status: {services_response.status_code}")
# Check if all services are running if services_response.ok:
all_running = True services = services_response.json()
service_statuses = [] current_app.logger.info(f"Found {len(services)} services for stack {data['stack_name']}")
for service in services: # Check if all services are running
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0) all_running = True
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0) service_statuses = []
service_status = { for service in services:
'name': service.get('Spec', {}).get('Name', 'Unknown'), replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0)
'replicas_expected': replicas_running, replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
'replicas_running': replicas_actual,
'status': 'running' if replicas_actual >= replicas_running else 'not_running'
}
service_statuses.append(service_status) service_status = {
'name': service.get('Spec', {}).get('Name', 'Unknown'),
'replicas_expected': replicas_running,
'replicas_running': replicas_actual,
'status': 'running' if replicas_actual >= replicas_running else 'not_running'
}
if replicas_actual < replicas_running: service_statuses.append(service_status)
all_running = False
# Determine overall stack status if replicas_actual < replicas_running:
if all_running and len(services) > 0: all_running = False
status = 'active'
elif len(services) > 0: # Determine overall stack status
status = 'partial' if all_running and len(services) > 0:
else: status = 'active'
status = 'inactive' elif len(services) > 0:
status = 'partial'
else:
status = 'inactive'
else:
# Services API failed, but stack exists - assume it's still starting up
current_app.logger.warning(f"Failed to get services for stack {data['stack_name']}: {services_response.status_code} - {services_response.text}")
# Provide more specific error context
if services_response.status_code == 404:
current_app.logger.info(f"Services endpoint not found for stack {data['stack_name']} - stack may still be initializing")
elif services_response.status_code == 403:
current_app.logger.warning(f"Access denied to services for stack {data['stack_name']} - check Portainer permissions")
elif services_response.status_code >= 500:
current_app.logger.warning(f"Portainer server error when getting services for stack {data['stack_name']}")
services = []
service_statuses = []
status = 'starting' # Stack exists but services not available yet
except Exception as e:
# Exception occurred while getting services, but stack exists
current_app.logger.warning(f"Exception getting services for stack {data['stack_name']}: {str(e)}")
services = []
service_statuses = []
status = 'starting' # Stack exists but services not available yet
return jsonify({ return jsonify({
'success': True, 'success': True,
@@ -1060,7 +1061,9 @@ def check_stack_status():
'status': status, 'status': status,
'services': service_statuses, 'services': service_statuses,
'total_services': len(services), 'total_services': len(services),
'running_services': len([s for s in service_statuses if s['status'] == 'running']) 'running_services': len([s for s in service_statuses if s['status'] == 'running']),
'stack_created_at': target_stack.get('CreatedAt', 'unknown'),
'stack_updated_at': target_stack.get('UpdatedAt', 'unknown')
} }
}) })

View File

@@ -350,7 +350,7 @@ def init_routes(main_bp):
try: try:
# Construct the health check URL # Construct the health check URL
health_url = f"{instance.main_url.rstrip('/')}/health" health_url = f"{instance.main_url.rstrip('/')}/health"
response = requests.get(health_url, timeout=5) response = requests.get(health_url, timeout=30) # Increased timeout to 30 seconds
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
@@ -386,47 +386,11 @@ def init_routes(main_bp):
gitea_repo = git_settings.get('repo') if git_settings else None gitea_repo = git_settings.get('repo') if git_settings else None
for instance in instances: for instance in instances:
# 1. Check status # Check status
status_info = check_instance_status(instance) status_info = check_instance_status(instance)
instance.status = status_info['status'] instance.status = status_info['status']
instance.status_details = status_info['details'] 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() db.session.commit()
portainer_settings = KeyValueSettings.get_value('portainer_settings') portainer_settings = KeyValueSettings.get_value('portainer_settings')
@@ -661,6 +625,188 @@ def init_routes(main_bp):
'is_valid': is_valid 'is_valid': is_valid
}) })
@main_bp.route('/instances/<int:instance_id>/version-info')
@login_required
@require_password_change
def instance_version_info(instance_id):
if not os.environ.get('MASTER', 'false').lower() == 'true':
return jsonify({'error': 'Unauthorized'}), 403
instance = Instance.query.get_or_404(instance_id)
# Check if instance has a connection token
if not instance.connection_token:
return jsonify({
'error': 'Instance not authenticated',
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch
})
try:
# Get JWT token using the connection token
jwt_response = requests.post(
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
headers={
'X-API-Key': instance.connection_token,
'Accept': 'application/json'
},
timeout=5
)
if jwt_response.status_code != 200:
return jsonify({
'error': 'Failed to authenticate with instance',
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch
})
jwt_data = jwt_response.json()
jwt_token = jwt_data.get('token')
if not jwt_token:
return jsonify({
'error': 'No JWT token received',
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch
})
# Fetch version information from the instance
response = requests.get(
f"{instance.main_url.rstrip('/')}/api/admin/version-info",
headers={
'Authorization': f'Bearer {jwt_token}',
'Accept': 'application/json'
},
timeout=5
)
if response.status_code == 200:
version_data = response.json()
# Update the instance with the fetched version information
instance.deployed_version = version_data.get('app_version', instance.deployed_version)
instance.deployed_branch = version_data.get('git_branch', instance.deployed_branch)
instance.version_checked_at = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch,
'git_commit': version_data.get('git_commit'),
'deployed_at': version_data.get('deployed_at'),
'version_checked_at': instance.version_checked_at.isoformat() if instance.version_checked_at else None
})
else:
return jsonify({
'error': f'Failed to fetch version info: {response.status_code}',
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch
})
except Exception as e:
current_app.logger.error(f"Error fetching version info: {str(e)}")
return jsonify({
'error': f'Error fetching version info: {str(e)}',
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch
})
@main_bp.route('/api/latest-version')
@login_required
@require_password_change
def get_latest_version():
if not os.environ.get('MASTER', 'false').lower() == 'true':
return jsonify({'error': 'Unauthorized'}), 403
try:
# Get Git settings
git_settings = KeyValueSettings.get_value('git_settings')
if not git_settings:
return jsonify({
'error': 'Git settings not configured',
'latest_version': 'unknown',
'latest_commit': 'unknown',
'last_checked': None
})
latest_tag = None
latest_commit = None
if git_settings['provider'] == 'gitea':
headers = {
'Accept': 'application/json',
'Authorization': f'token {git_settings["token"]}'
}
# Get the latest tag
tags_response = requests.get(
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags',
headers=headers,
timeout=10
)
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')
latest_commit = sorted_tags[0].get('commit', {}).get('id')
else:
# Try token as query parameter if header auth fails
tags_response = requests.get(
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags?token={git_settings["token"]}',
headers={'Accept': 'application/json'},
timeout=10
)
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')
latest_commit = sorted_tags[0].get('commit', {}).get('id')
elif git_settings['provider'] == 'gitlab':
headers = {
'PRIVATE-TOKEN': git_settings['token'],
'Accept': 'application/json'
}
# Get the latest tag
tags_response = requests.get(
f'{git_settings["url"]}/api/v4/projects/{git_settings["repo"].replace("/", "%2F")}/repository/tags',
headers=headers,
params={'order_by': 'version', 'sort': 'desc', 'per_page': 1},
timeout=10
)
if tags_response.status_code == 200:
tags_data = tags_response.json()
if tags_data:
latest_tag = tags_data[0].get('name')
latest_commit = tags_data[0].get('commit', {}).get('id')
return jsonify({
'success': True,
'latest_version': latest_tag or 'unknown',
'latest_commit': latest_commit or 'unknown',
'repository': git_settings.get('repo', 'unknown'),
'provider': git_settings.get('provider', 'unknown'),
'last_checked': datetime.utcnow().isoformat()
})
except Exception as e:
current_app.logger.error(f"Error fetching latest version: {str(e)}")
return jsonify({
'error': f'Error fetching latest version: {str(e)}',
'latest_version': 'unknown',
'latest_commit': 'unknown',
'last_checked': datetime.utcnow().isoformat()
}), 500
UPLOAD_FOLDER = '/app/uploads/profile_pics' UPLOAD_FOLDER = '/app/uploads/profile_pics'
if not os.path.exists(UPLOAD_FOLDER): if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER) os.makedirs(UPLOAD_FOLDER)
@@ -2019,32 +2165,16 @@ def init_routes(main_bp):
@main_bp.route('/api/version') @main_bp.route('/api/version')
def api_version(): def api_version():
version_file = os.path.join(current_app.root_path, 'version.txt') # Get version information from environment variables
version = 'unknown' version = os.getenv('APP_VERSION', 'unknown')
version_data = {} commit = os.getenv('GIT_COMMIT', 'unknown')
branch = os.getenv('GIT_BRANCH', 'unknown')
if os.path.exists(version_file): deployed_at = os.getenv('DEPLOYED_AT', 'unknown')
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({ return jsonify({
'version': version, 'version': version,
'tag': version_data.get('tag', 'unknown'), 'tag': version,
'commit': version_data.get('commit', 'unknown'), 'commit': commit,
'branch': version_data.get('branch', 'unknown'), 'branch': branch,
'deployed_at': version_data.get('deployed_at', 'unknown') 'deployed_at': deployed_at
}) })

59
set_version.py Normal file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Utility script to set version environment variables for local development.
This replaces the need for version.txt file creation.
"""
import os
import subprocess
import json
from datetime import datetime
def get_git_info():
"""Get current git commit hash and branch"""
try:
# Get current commit hash
commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'],
text=True, stderr=subprocess.DEVNULL).strip()
# Get current branch
branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
text=True, stderr=subprocess.DEVNULL).strip()
# Get latest tag
try:
latest_tag = subprocess.check_output(['git', 'describe', '--tags', '--abbrev=0'],
text=True, stderr=subprocess.DEVNULL).strip()
except subprocess.CalledProcessError:
latest_tag = 'unknown'
return {
'commit': commit_hash,
'branch': branch,
'tag': latest_tag
}
except (subprocess.CalledProcessError, FileNotFoundError):
return {
'commit': 'unknown',
'branch': 'unknown',
'tag': 'unknown'
}
def set_version_env():
"""Set version environment variables"""
git_info = get_git_info()
# Set environment variables
os.environ['APP_VERSION'] = git_info['tag']
os.environ['GIT_COMMIT'] = git_info['commit']
os.environ['GIT_BRANCH'] = git_info['branch']
os.environ['DEPLOYED_AT'] = datetime.utcnow().isoformat()
print("Version environment variables set:")
print(f"APP_VERSION: {os.environ['APP_VERSION']}")
print(f"GIT_COMMIT: {os.environ['GIT_COMMIT']}")
print(f"GIT_BRANCH: {os.environ['GIT_BRANCH']}")
print(f"DEPLOYED_AT: {os.environ['DEPLOYED_AT']}")
if __name__ == '__main__':
set_version_env()

View File

@@ -45,6 +45,10 @@
background-color: #ffebee; background-color: #ffebee;
} }
.step-item.warning {
background-color: #fff3cd;
}
.step-icon { .step-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -72,6 +76,11 @@
color: white; color: white;
} }
.step-item.warning .step-icon {
background-color: #ffc107;
color: white;
}
.step-content { .step-content {
flex-grow: 1; flex-grow: 1;
} }
@@ -93,3 +102,7 @@
.step-item.failed .step-status { .step-item.failed .step-status {
color: #dc3545; color: #dc3545;
} }
.step-item.warning .step-status {
color: #856404;
}

View File

@@ -451,6 +451,11 @@ async function startLaunch(data) {
throw new Error(dockerComposeResult.error || 'Failed to download docker-compose.yml'); throw new Error(dockerComposeResult.error || 'Failed to download docker-compose.yml');
} }
// Set global version variables for deployment
window.currentDeploymentVersion = dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown';
window.currentDeploymentCommit = dockerComposeResult.commit_hash || 'unknown';
window.currentDeploymentBranch = data.branch;
// Update the step to show success // Update the step to show success
const dockerComposeStep = document.querySelectorAll('.step-item')[7]; const dockerComposeStep = document.querySelectorAll('.step-item')[7];
dockerComposeStep.classList.remove('active'); dockerComposeStep.classList.remove('active');
@@ -476,14 +481,62 @@ async function startLaunch(data) {
// Step 9: Deploy Stack // Step 9: Deploy Stack
await updateStep(9, 'Deploying Stack', 'Launching your application stack...'); await updateStep(9, 'Deploying Stack', 'Launching your application stack...');
const stackResult = await deployStack(dockerComposeResult.content, data.instanceName, data.port);
// Add progress indicator for stack deployment
const stackDeployStepElement = document.querySelectorAll('.step-item')[8];
const stackProgressDiv = document.createElement('div');
stackProgressDiv.className = 'mt-2';
stackProgressDiv.innerHTML = `
<div class="progress" style="height: 20px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 0%"
id="stackProgress">
0%
</div>
</div>
<small class="text-muted" id="stackProgressText">Initiating stack deployment...</small>
`;
stackDeployStepElement.querySelector('.step-content').appendChild(stackProgressDiv);
const stackResult = await deployStack(dockerComposeResult.content, `docupulse_${data.port}`, data.port);
launchReport.steps.push({ launchReport.steps.push({
step: 'Stack Deployment', step: 'Stack Deployment',
status: stackResult.success ? 'success' : 'error', status: stackResult.success ? 'success' : 'error',
details: stackResult details: stackResult
}); });
// Handle different stack deployment scenarios
if (!stackResult.success) { if (!stackResult.success) {
throw new Error(stackResult.error || 'Failed to deploy stack'); // Check if this is a timeout but the stack might still be deploying
if (stackResult.error && stackResult.error.includes('timed out')) {
console.log('Stack deployment timed out, but may still be in progress');
// Update the step to show warning instead of error
const stackDeployStep = document.querySelectorAll('.step-item')[8];
stackDeployStep.classList.remove('active');
stackDeployStep.classList.add('warning');
stackDeployStep.querySelector('.step-status').textContent = 'Stack deployment timed out but may still be in progress';
// Add a note about the timeout
const timeoutNote = document.createElement('div');
timeoutNote.className = 'alert alert-warning mt-2';
timeoutNote.innerHTML = `
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Note:</strong> The stack deployment request timed out, but the deployment may still be in progress.
You can check the status in your Portainer dashboard or wait a few minutes and refresh this page.
`;
stackDeployStep.querySelector('.step-content').appendChild(timeoutNote);
// Continue with the process using the available data
stackResult.data = stackResult.data || {
name: `docupulse_${data.port}`,
status: 'creating',
id: null
};
} else {
throw new Error(stackResult.error || 'Failed to deploy stack');
}
} }
// Update the step to show success // Update the step to show success
@@ -493,6 +546,8 @@ async function startLaunch(data) {
stackDeployStep.querySelector('.step-status').textContent = stackDeployStep.querySelector('.step-status').textContent =
stackResult.data.status === 'existing' ? stackResult.data.status === 'existing' ?
'Using existing stack' : 'Using existing stack' :
stackResult.data.status === 'active' ?
'Successfully deployed and activated stack' :
'Successfully deployed stack'; 'Successfully deployed stack';
// Add stack details // Add stack details
@@ -517,13 +572,13 @@ async function startLaunch(data) {
</tr> </tr>
<tr> <tr>
<td>Stack ID</td> <td>Stack ID</td>
<td>${stackResult.data.id}</td> <td>${stackResult.data.id || 'Will be determined during deployment'}</td>
</tr> </tr>
<tr> <tr>
<td>Status</td> <td>Status</td>
<td> <td>
<span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : 'success'}"> <span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : stackResult.data.status === 'active' ? 'success' : 'warning'}">
${stackResult.data.status === 'existing' ? 'Existing' : 'Deployed'} ${stackResult.data.status === 'existing' ? 'Existing' : stackResult.data.status === 'active' ? 'Active' : 'Deployed'}
</span> </span>
</td> </td>
</tr> </tr>
@@ -542,7 +597,7 @@ async function startLaunch(data) {
name: data.instanceName, name: data.instanceName,
port: data.port, port: data.port,
domains: data.webAddresses, domains: data.webAddresses,
stack_id: stackResult.data.id, stack_id: stackResult.data.id || null, // May be null if we got a 504
stack_name: stackResult.data.name, stack_name: stackResult.data.name,
status: stackResult.data.status, status: stackResult.data.status,
repository: data.repository, repository: data.repository,
@@ -571,40 +626,26 @@ async function startLaunch(data) {
saveDataStep.classList.add('completed'); saveDataStep.classList.add('completed');
saveDataStep.querySelector('.step-status').textContent = 'Successfully saved instance data'; saveDataStep.querySelector('.step-status').textContent = 'Successfully saved instance data';
// Add instance details
const instanceDetails = document.createElement('div');
instanceDetails.className = 'mt-3';
instanceDetails.innerHTML = `
<div class="card">
<div class="card-body">
<h6 class="card-title mb-3">Instance Information</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Internal Port</td>
<td>${data.port}</td>
</tr>
<tr>
<td>Domains</td>
<td><a href="https://${data.webAddresses.join(', ')}" target="_blank">${data.webAddresses.join(', ')}</a></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`;
saveDataStep.querySelector('.step-content').appendChild(instanceDetails);
// After saving instance data, add the health check step // After saving instance data, add the health check step
await updateStep(11, 'Health Check', 'Verifying instance health...'); await updateStep(11, 'Health Check', 'Verifying instance health...');
// Add a progress indicator
const healthStepElement = document.querySelectorAll('.step-item')[10];
const progressDiv = document.createElement('div');
progressDiv.className = 'mt-2';
progressDiv.innerHTML = `
<div class="progress" style="height: 20px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 0%"
id="healthProgress">
0%
</div>
</div>
<small class="text-muted" id="healthProgressText">Starting health check...</small>
`;
healthStepElement.querySelector('.step-content').appendChild(progressDiv);
const healthResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`); const healthResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
if (!healthResult.success) { if (!healthResult.success) {
@@ -612,7 +653,7 @@ async function startLaunch(data) {
} }
// Add a retry button if health check fails // Add a retry button if health check fails
const healthStep = document.querySelectorAll('.step-item')[10]; const healthStepElement2 = document.querySelectorAll('.step-item')[10];
if (!healthResult.success) { if (!healthResult.success) {
const retryButton = document.createElement('button'); const retryButton = document.createElement('button');
retryButton.className = 'btn btn-sm btn-warning mt-2'; retryButton.className = 'btn btn-sm btn-warning mt-2';
@@ -622,15 +663,15 @@ async function startLaunch(data) {
retryButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Checking...'; retryButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Checking...';
const retryResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`); const retryResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
if (retryResult.success) { if (retryResult.success) {
healthStep.classList.remove('failed'); healthStepElement2.classList.remove('failed');
healthStep.classList.add('completed'); healthStepElement2.classList.add('completed');
retryButton.remove(); retryButton.remove();
} else { } else {
retryButton.disabled = false; retryButton.disabled = false;
retryButton.innerHTML = '<i class="fas fa-sync-alt me-1"></i> Retry Health Check'; retryButton.innerHTML = '<i class="fas fa-sync-alt me-1"></i> Retry Health Check';
} }
}; };
healthStep.querySelector('.step-content').appendChild(retryButton); healthStepElement2.querySelector('.step-content').appendChild(retryButton);
} }
// After health check, add authentication step // After health check, add authentication step
@@ -1973,7 +2014,9 @@ async function downloadDockerCompose(repo, branch) {
const result = await response.json(); const result = await response.json();
return { return {
success: true, success: true,
content: result.content content: result.content,
commit_hash: result.commit_hash,
latest_tag: result.latest_tag
}; };
} catch (error) { } catch (error) {
console.error('Error downloading docker-compose.yml:', error); console.error('Error downloading docker-compose.yml:', error);
@@ -1984,185 +2027,6 @@ async function downloadDockerCompose(repo, branch) {
} }
} }
// Add new function to deploy stack
async function deployStack(dockerComposeContent, stackName, port) {
const maxRetries = 30; // 30 retries * 10 seconds = 5 minutes
const baseDelay = 10000; // 10 seconds base delay
try {
// First, attempt to deploy the stack
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10 * 60 * 1000); // 10 minutes timeout
const response = await fetch('/api/admin/deploy-stack', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
name: `docupulse_${port}`,
StackFileContent: dockerComposeContent,
Env: [
{
name: 'PORT',
value: port.toString()
},
{
name: 'ISMASTER',
value: 'false'
}
]
}),
signal: controller.signal
});
clearTimeout(timeoutId); // Clear the timeout if the request completes
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to deploy stack');
}
const result = await response.json();
// If deployment was successful, wait for stack to come online
if (result.success || result.data) {
console.log('Stack deployment initiated, waiting for stack to come online...');
// Update status to show we're waiting for stack to come online
const currentStep = document.querySelector('.step-item.active');
if (currentStep) {
const statusElement = currentStep.querySelector('.step-status');
statusElement.textContent = 'Stack deployed, waiting for services to start...';
}
// Wait and retry to check if stack is online
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Check stack status via Portainer API
const stackCheckResponse = await fetch('/api/admin/check-stack-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
stack_name: `docupulse_${port}`
})
});
if (stackCheckResponse.ok) {
const stackStatus = await stackCheckResponse.json();
if (stackStatus.success && stackStatus.data.status === 'active') {
console.log(`Stack came online successfully on attempt ${attempt}`);
// Update status to show success
if (currentStep) {
const statusElement = currentStep.querySelector('.step-status');
statusElement.textContent = 'Stack deployed and online successfully';
}
return {
success: true,
data: {
...result.data || result,
status: 'active',
attempt: attempt
}
};
}
}
// If not online yet and this isn't the last attempt, wait and retry
if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(1.2, attempt - 1); // Exponential backoff
console.log(`Attempt ${attempt}/${maxRetries}: Stack not yet online. Waiting ${Math.round(delay/1000)}s before retry...`);
// Update the step description to show retry progress
if (currentStep) {
const statusElement = currentStep.querySelector('.step-status');
statusElement.textContent = `Waiting for stack to come online... (Attempt ${attempt}/${maxRetries})`;
}
await new Promise(resolve => setTimeout(resolve, delay));
} else {
// Last attempt failed - stack might be online but API check failed
console.log(`Stack status check failed after ${maxRetries} attempts, but deployment was successful`);
// Update status to show partial success
if (currentStep) {
const statusElement = currentStep.querySelector('.step-status');
statusElement.textContent = 'Stack deployed (status check timeout)';
}
return {
success: true,
data: {
...result.data || result,
status: 'deployed',
note: 'Status check timeout - stack may be online'
}
};
}
} catch (error) {
console.error(`Stack status check attempt ${attempt} failed:`, error);
if (attempt === maxRetries) {
// Last attempt failed, but deployment was successful
console.log('Stack status check failed after all attempts, but deployment was successful');
if (currentStep) {
const statusElement = currentStep.querySelector('.step-status');
statusElement.textContent = 'Stack deployed (status check failed)';
}
return {
success: true,
data: {
...result.data || result,
status: 'deployed',
note: 'Status check failed - stack may be online'
}
};
}
// Wait before retrying on error
const delay = baseDelay * Math.pow(1.2, attempt - 1);
console.log(`Stack check failed, retrying in ${Math.round(delay/1000)}s...`);
if (currentStep) {
const statusElement = currentStep.querySelector('.step-status');
statusElement.textContent = `Stack check failed, retrying... (Attempt ${attempt}/${maxRetries})`;
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// If we get here, deployment was successful but we couldn't verify status
return {
success: true,
data: {
...result.data || result,
status: 'deployed',
note: 'Deployment successful, status unknown'
}
};
} catch (error) {
console.error('Error deploying stack:', error);
return {
success: false,
error: error.message
};
}
}
// Add new function to save instance data // Add new function to save instance data
async function saveInstanceData(instanceData) { async function saveInstanceData(instanceData) {
try { try {
@@ -2194,7 +2058,7 @@ async function saveInstanceData(instanceData) {
main_url: `https://${instanceData.domains[0]}`, main_url: `https://${instanceData.domains[0]}`,
status: 'inactive', status: 'inactive',
port: instanceData.port, port: instanceData.port,
stack_id: instanceData.stack_id, stack_id: instanceData.stack_id || '', // Use empty string if null
stack_name: instanceData.stack_name, stack_name: instanceData.stack_name,
repository: instanceData.repository, repository: instanceData.repository,
branch: instanceData.branch branch: instanceData.branch
@@ -2219,7 +2083,7 @@ async function saveInstanceData(instanceData) {
main_url: `https://${instanceData.domains[0]}`, main_url: `https://${instanceData.domains[0]}`,
status: 'inactive', status: 'inactive',
port: instanceData.port, port: instanceData.port,
stack_id: instanceData.stack_id, stack_id: instanceData.stack_id || '', // Use empty string if null
stack_name: instanceData.stack_name, stack_name: instanceData.stack_name,
repository: instanceData.repository, repository: instanceData.repository,
branch: instanceData.branch branch: instanceData.branch
@@ -2242,11 +2106,19 @@ async function saveInstanceData(instanceData) {
} }
async function checkInstanceHealth(instanceUrl) { async function checkInstanceHealth(instanceUrl) {
const maxRetries = 5; const maxRetries = 120; // 120 retries * 5 seconds = 10 minutes total
const baseDelay = 5000; // 5 seconds base delay
let currentAttempt = 1; let currentAttempt = 1;
const startTime = Date.now();
const maxTotalTime = 10 * 60 * 1000; // 10 minutes in milliseconds
while (currentAttempt <= maxRetries) { while (currentAttempt <= maxRetries) {
try { try {
// Check if we've exceeded the total timeout
if (Date.now() - startTime > maxTotalTime) {
throw new Error('Health check timeout: 10 minutes exceeded');
}
// First get the instance ID from the database // First get the instance ID from the database
const response = await fetch('/instances'); const response = await fetch('/instances');
const text = await response.text(); const text = await response.text();
@@ -2280,42 +2152,102 @@ async function checkInstanceHealth(instanceUrl) {
const data = await statusResponse.json(); const data = await statusResponse.json();
// Update the health check step // Update the health check step
const healthStep = document.querySelectorAll('.step-item')[8]; // Adjust index based on your steps const healthStepElement = document.querySelectorAll('.step-item')[10]; // Adjust index based on your steps
healthStep.classList.remove('active'); healthStepElement.classList.remove('active');
healthStep.classList.add('completed'); healthStepElement.classList.add('completed');
const statusText = healthStep.querySelector('.step-status'); const statusText = healthStepElement.querySelector('.step-status');
if (data.status === 'active') { if (data.status === 'active') {
statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries})`; const elapsedTime = Math.round((Date.now() - startTime) / 1000);
statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed)`;
// Update progress bar to 100%
const progressBar = document.getElementById('healthProgress');
const progressText = document.getElementById('healthProgressText');
if (progressBar && progressText) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
progressText.textContent = `Health check completed successfully in ${elapsedTime}s`;
}
return { return {
success: true, success: true,
data: data data: data,
attempts: currentAttempt,
elapsedTime: elapsedTime
}; };
} else if (data.status === 'inactive') {
console.log(`Stack ${stackName} is inactive, continuing to poll...`);
lastKnownStatus = 'inactive';
if (progressText) {
progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
} else if (data.status === 'starting') {
console.log(`Stack ${stackName} is starting up, continuing to poll...`);
lastKnownStatus = 'starting';
if (progressText) {
progressText.textContent = `Stack is initializing (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
} else { } else {
throw new Error('Instance is not healthy'); throw new Error('Instance is not healthy');
} }
} catch (error) { } catch (error) {
console.error(`Health check attempt ${currentAttempt} failed:`, error); console.error(`Health check attempt ${currentAttempt} failed:`, error);
// Update status to show current attempt // Update status to show current attempt and elapsed time
const healthStep = document.querySelectorAll('.step-item')[8]; const healthStepElement = document.querySelectorAll('.step-item')[10];
const statusText = healthStep.querySelector('.step-status'); const statusText = healthStepElement.querySelector('.step-status');
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}): ${error.message}`; const elapsedTime = Math.round((Date.now() - startTime) / 1000);
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`;
// Update progress bar
const progressBar = document.getElementById('healthProgress');
const progressText = document.getElementById('healthProgressText');
if (progressBar && progressText) {
const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100);
progressBar.style.width = `${progressPercent}%`;
progressBar.textContent = `${Math.round(progressPercent)}%`;
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
}
if (currentAttempt === maxRetries || (Date.now() - startTime > maxTotalTime)) {
// Update progress bar to show failure
if (progressBar && progressText) {
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-danger');
progressText.textContent = `Health check failed after ${currentAttempt} attempts (${elapsedTime}s)`;
}
if (currentAttempt === maxRetries) {
return { return {
success: false, success: false,
error: `Health check failed after ${maxRetries} attempts: ${error.message}` error: `Health check failed after ${currentAttempt} attempts (${elapsedTime}s): ${error.message}`
}; };
} }
// Wait 5 seconds before next attempt // Wait before next attempt (5 seconds base delay)
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, baseDelay));
currentAttempt++; currentAttempt++;
// Update progress bar in real-time
updateHealthProgress(currentAttempt, maxRetries, elapsedTime);
} }
} }
} }
function updateHealthProgress(currentAttempt, maxRetries, elapsedTime) {
const progressBar = document.getElementById('healthProgress');
const progressText = document.getElementById('healthProgressText');
if (progressBar && progressText) {
const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100);
progressBar.style.width = `${progressPercent}%`;
progressBar.textContent = `${Math.round(progressPercent)}%`;
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
}
}
async function authenticateInstance(instanceUrl, instanceId) { async function authenticateInstance(instanceUrl, instanceId) {
try { try {
// First check if instance is already authenticated // First check if instance is already authenticated
@@ -2626,3 +2558,289 @@ async function sendCompletionEmail(instanceUrl, company, credentials) {
}; };
} }
} }
// Add new function to check if stack exists
async function checkStackExists(stackName) {
try {
const response = await fetch('/api/admin/check-stack-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
stack_name: stackName
})
});
if (response.ok) {
const result = await response.json();
return {
exists: true,
status: result.data?.status || 'unknown',
data: result.data
};
} else {
return {
exists: false,
status: 'not_found'
};
}
} catch (error) {
console.error('Error checking stack existence:', error);
return {
exists: false,
status: 'error',
error: error.message
};
}
}
// Add new function to deploy stack
async function deployStack(dockerComposeContent, stackName, port) {
try {
// First, attempt to deploy the stack
const response = await fetch('/api/admin/deploy-stack', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
name: `docupulse_${port}`,
StackFileContent: dockerComposeContent,
Env: [
{
name: 'PORT',
value: port.toString()
},
{
name: 'ISMASTER',
value: 'false'
},
{
name: 'APP_VERSION',
value: window.currentDeploymentVersion || 'unknown'
},
{
name: 'GIT_COMMIT',
value: window.currentDeploymentCommit || 'unknown'
},
{
name: 'GIT_BRANCH',
value: window.currentDeploymentBranch || 'unknown'
},
{
name: 'DEPLOYED_AT',
value: new Date().toISOString()
}
]
})
});
// Handle 504 Gateway Timeout as successful initiation
if (response.status === 504) {
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
// Update progress to show that we're now polling
const progressBar = document.getElementById('stackProgress');
const progressText = document.getElementById('stackProgressText');
if (progressBar && progressText) {
progressBar.style.width = '25%';
progressBar.textContent = '25%';
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
}
// Start polling immediately since the stack creation was initiated
console.log('Starting to poll for stack status after 504 timeout...');
const pollResult = await pollStackStatus(`docupulse_${port}`, 15 * 60 * 1000); // 15 minutes max
return pollResult;
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to deploy stack');
}
const result = await response.json();
console.log('Stack deployment initiated:', result);
// If stack is being created, poll for status
if (result.data.status === 'creating') {
console.log('Stack is being created, polling for status...');
const pollResult = await pollStackStatus(`docupulse_${port}`, 10 * 60 * 1000); // 10 minutes max
return pollResult;
}
// Return success result with response data
return {
success: true,
data: result.data
};
} catch (error) {
console.error('Error deploying stack:', error);
return {
success: false,
error: error.message
};
}
}
// Function to poll stack status
async function pollStackStatus(stackName, maxWaitTime = 15 * 60 * 1000) {
const startTime = Date.now();
const pollInterval = 5000; // 5 seconds
let attempts = 0;
let lastKnownStatus = 'unknown';
// Validate stack name
if (!stackName || typeof stackName !== 'string') {
console.error('Invalid stack name provided to pollStackStatus:', stackName);
return {
success: false,
error: `Invalid stack name: ${stackName}`,
data: {
name: stackName,
status: 'error'
}
};
}
console.log(`Starting to poll stack status for: ${stackName} (max wait: ${maxWaitTime / 1000}s)`);
// Update progress indicator
const progressBar = document.getElementById('stackProgress');
const progressText = document.getElementById('stackProgressText');
while (Date.now() - startTime < maxWaitTime) {
attempts++;
console.log(`Polling attempt ${attempts} for stack: ${stackName}`);
// Update progress - start at 25% if we came from a 504 timeout, otherwise start at 0%
const elapsed = Date.now() - startTime;
const baseProgress = progressBar && progressBar.style.width === '25%' ? 25 : 0;
const progress = Math.min(baseProgress + (elapsed / maxWaitTime) * 70, 95); // Cap at 95% until complete
if (progressBar && progressText) {
progressBar.style.width = `${progress}%`;
progressBar.textContent = `${Math.round(progress)}%`;
}
try {
const requestBody = {
stack_name: stackName
};
console.log(`Sending stack status check request:`, requestBody);
const response = await fetch('/api/admin/check-stack-status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(requestBody),
timeout: 30000 // 30 second timeout for status checks
});
if (response.ok) {
const result = await response.json();
console.log(`Stack status check result:`, result);
if (result.data && result.data.status === 'active') {
console.log(`Stack ${stackName} is now active!`);
// Update progress to 100%
if (progressBar && progressText) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
progressText.textContent = 'Stack is now active and running!';
}
return {
success: true,
data: {
name: stackName,
id: result.data.stack_id,
status: 'active'
}
};
} else if (result.data && result.data.status === 'partial') {
console.log(`Stack ${stackName} is partially running, continuing to poll...`);
lastKnownStatus = 'partial';
if (progressText) {
progressText.textContent = `Stack is partially running (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
} else if (result.data && result.data.status === 'inactive') {
console.log(`Stack ${stackName} is inactive, continuing to poll...`);
lastKnownStatus = 'inactive';
if (progressText) {
progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
} else if (result.data && result.data.status === 'starting') {
console.log(`Stack ${stackName} exists and is starting up - continuing to next step`);
// Stack exists, we can continue - no need to wait for all services
if (progressBar && progressText) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
progressText.textContent = 'Stack created successfully!';
}
return {
success: true,
data: {
name: stackName,
id: result.data.stack_id,
status: 'starting'
}
};
} else {
console.log(`Stack ${stackName} status unknown, continuing to poll...`);
lastKnownStatus = 'unknown';
if (progressText) {
progressText.textContent = `Checking stack status (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
}
} else if (response.status === 404) {
console.log(`Stack ${stackName} not found yet, continuing to poll...`);
lastKnownStatus = 'not_found';
if (progressText) {
progressText.textContent = `Stack not found yet (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
} else {
console.log(`Stack status check failed with status ${response.status}, continuing to poll...`);
if (progressText) {
progressText.textContent = `Status check failed (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
}
} catch (error) {
console.error(`Error polling stack status (attempt ${attempts}):`, error);
if (progressText) {
progressText.textContent = `Error checking status (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
}
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
// If we get here, we've timed out
console.error(`Stack status polling timed out after ${maxWaitTime / 1000} seconds`);
if (progressBar && progressText) {
progressBar.style.width = '100%';
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-warning');
progressText.textContent = `Stack deployment timed out after ${Math.round(maxWaitTime / 1000)}s. Last known status: ${lastKnownStatus}`;
}
// Return a more informative error message
const statusMessage = lastKnownStatus !== 'unknown' ? ` (last known status: ${lastKnownStatus})` : '';
return {
success: false,
error: `Stack deployment timed out after ${Math.round(maxWaitTime / 1000)} seconds${statusMessage}. The stack may still be deploying in the background.`,
data: {
name: stackName,
status: lastKnownStatus
}
};
}

View File

@@ -5,48 +5,106 @@
{% block extra_css %} {% block extra_css %}
<style> <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 { .table td {
vertical-align: middle; vertical-align: middle;
} }
/* Version column styling */
.version-badge {
font-family: monospace;
font-size: 0.85em;
}
.branch-badge {
font-size: 0.85em;
}
/* Make table responsive */
.table-responsive {
overflow-x: auto;
}
/* Tooltip styling for version info */
.tooltip-inner {
max-width: 300px;
text-align: left;
}
/* Version comparison styling */
.version-outdated {
background-color: #fff3cd !important;
border-color: #ffeaa7 !important;
}
.version-current {
background-color: #d1ecf1 !important;
border-color: #bee5eb !important;
}
.badge.bg-orange {
background-color: #fd7e14 !important;
color: white !important;
}
.badge.bg-orange:hover {
background-color: #e55a00 !important;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{{ header( {{ header(
title="Instances", title="Instance Management",
description="Manage your DocuPulse instances", description="Manage and monitor your DocuPulse instances",
icon="fa-server", icon="fa-server",
buttons=[ buttons=[
{ {
'text': 'Launch New Instance', 'text': 'Launch New Instance',
'url': '#', 'onclick': 'showAddInstanceModal()',
'icon': 'fa-rocket', 'icon': 'fa-plus',
'class': 'btn-primary', 'class': 'btn-primary'
'onclick': 'showAddInstanceModal()'
},
{
'text': 'Add Existing Instance',
'url': '#',
'icon': 'fa-link',
'class': 'btn-primary',
'onclick': 'showAddExistingInstanceModal()'
} }
] ]
) }} ) }}
<!-- Latest Version Information -->
<div class="container-fluid mb-4">
<div class="row">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h5 class="card-title mb-2">
<i class="fas fa-code-branch me-2" style="color: var(--primary-color);"></i>
Latest Available Version
</h5>
<div class="d-flex align-items-center">
<div class="me-4">
<span class="badge bg-success fs-6" id="latestVersionBadge">
<i class="fas fa-spinner fa-spin me-1"></i> Loading...
</span>
</div>
<div>
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
Last checked: <span id="lastChecked" class="text-muted">Loading...</span>
</small>
</div>
</div>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-outline-primary btn-sm" onclick="refreshLatestVersion()">
<i class="fas fa-sync-alt me-1"></i> Refresh
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
@@ -97,41 +155,13 @@
</span> </span>
</td> </td>
<td> <td>
{% if instance.deployed_version and instance.deployed_version != 'unknown' %} {% if instance.deployed_version %}
<div class="version-info"> <span class="badge bg-info version-badge" data-bs-toggle="tooltip"
<div class="small"> title="Deployed: {{ instance.deployed_version }}{% if instance.version_checked_at %}<br>Checked: {{ instance.version_checked_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}">
<strong>Version:</strong> {{ instance.deployed_version[:8] if instance.deployed_version != 'unknown' else 'unknown' }}
<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> </span>
{% else %}
<span class="badge bg-secondary version-badge">unknown</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@@ -704,26 +734,38 @@ document.addEventListener('DOMContentLoaded', function() {
const headerButtons = document.querySelector('.header-buttons'); const headerButtons = document.querySelector('.header-buttons');
if (headerButtons) { if (headerButtons) {
const refreshButton = document.createElement('button'); const refreshButton = document.createElement('button');
refreshButton.className = 'btn btn-outline-primary'; refreshButton.className = 'btn btn-outline-primary me-2';
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh'; refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh All';
refreshButton.onclick = function() { refreshButton.onclick = function() {
fetchCompanyNames(); fetchCompanyNames();
}; };
headerButtons.appendChild(refreshButton); headerButtons.appendChild(refreshButton);
const versionRefreshButton = document.createElement('button');
versionRefreshButton.className = 'btn btn-outline-info';
versionRefreshButton.innerHTML = '<i class="fas fa-code-branch"></i> Refresh Versions';
versionRefreshButton.onclick = function() {
refreshAllVersionInfo();
};
headerButtons.appendChild(versionRefreshButton);
} }
// Wait a short moment to ensure the table is rendered // Wait a short moment to ensure the table is rendered
setTimeout(() => { setTimeout(async () => {
// Check statuses on page load // First fetch latest version information
checkAllInstanceStatuses(); await fetchLatestVersion();
// Fetch company names for all instances // Then check statuses and fetch company names
checkAllInstanceStatuses();
fetchCompanyNames(); fetchCompanyNames();
}, 100); }, 100);
// Set up periodic status checks (every 30 seconds) // Set up periodic status checks (every 30 seconds)
setInterval(checkAllInstanceStatuses, 30000); setInterval(checkAllInstanceStatuses, 30000);
// Set up periodic latest version checks (every 5 minutes)
setInterval(fetchLatestVersion, 300000);
// Update color picker functionality // Update color picker functionality
const primaryColor = document.getElementById('primaryColor'); const primaryColor = document.getElementById('primaryColor');
const secondaryColor = document.getElementById('secondaryColor'); const secondaryColor = document.getElementById('secondaryColor');
@@ -766,12 +808,25 @@ document.addEventListener('DOMContentLoaded', function() {
updateColorPreview(); updateColorPreview();
}); });
// Function to check status of all instances // Function to check all instance statuses
async function checkAllInstanceStatuses() { async function checkAllInstanceStatuses() {
const statusBadges = document.querySelectorAll('[data-instance-id]'); console.log('Checking all instance statuses...');
for (const badge of statusBadges) { const instances = document.querySelectorAll('[data-instance-id]');
for (const badge of instances) {
const instanceId = badge.dataset.instanceId; const instanceId = badge.dataset.instanceId;
await checkInstanceStatus(instanceId); await checkInstanceStatus(instanceId);
// Also refresh version info when checking status
const instanceUrl = badge.closest('tr').querySelector('td:nth-child(7) a')?.href;
const apiKey = badge.dataset.token;
if (instanceUrl && apiKey) {
// Fetch version info in the background (don't await to avoid blocking status checks)
fetchVersionInfo(instanceUrl, instanceId).catch(error => {
console.error(`Error fetching version info for instance ${instanceId}:`, error);
});
}
} }
} }
@@ -861,19 +916,19 @@ async function fetchInstanceStats(instanceUrl, instanceId, jwtToken) {
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr'); const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
// Update rooms count // Update rooms count (3rd column)
const roomsCell = row.querySelector('td:nth-child(3)'); const roomsCell = row.querySelector('td:nth-child(3)');
if (roomsCell) { if (roomsCell) {
roomsCell.textContent = data.rooms || '0'; roomsCell.textContent = data.rooms || '0';
} }
// Update conversations count // Update conversations count (4th column)
const conversationsCell = row.querySelector('td:nth-child(4)'); const conversationsCell = row.querySelector('td:nth-child(4)');
if (conversationsCell) { if (conversationsCell) {
conversationsCell.textContent = data.conversations || '0'; conversationsCell.textContent = data.conversations || '0';
} }
// Update data usage // Update data usage (5th column)
const dataCell = row.querySelector('td:nth-child(5)'); const dataCell = row.querySelector('td:nth-child(5)');
if (dataCell) { if (dataCell) {
const dataSize = data.total_storage || 0; const dataSize = data.total_storage || 0;
@@ -888,6 +943,193 @@ async function fetchInstanceStats(instanceUrl, instanceId, jwtToken) {
} }
} }
// Function to compare semantic versions and determine update type
function compareSemanticVersions(currentVersion, latestVersion) {
try {
// Parse versions into parts (handle cases like "1.0" or "1.0.0")
const parseVersion = (version) => {
const parts = version.split('.').map(part => {
const num = parseInt(part, 10);
return isNaN(num) ? 0 : num;
});
// Ensure we have at least 3 parts (major.minor.patch)
while (parts.length < 3) {
parts.push(0);
}
return parts.slice(0, 3); // Only take first 3 parts
};
const current = parseVersion(currentVersion);
const latest = parseVersion(latestVersion);
// Compare major version
if (current[0] < latest[0]) {
return 'major';
}
// Compare minor version
if (current[1] < latest[1]) {
return 'minor';
}
// Compare patch version
if (current[2] < latest[2]) {
return 'patch';
}
// If we get here, current version is newer or equal
return 'up_to_date';
} catch (error) {
console.error('Error comparing semantic versions:', error);
return 'unknown';
}
}
// Function to fetch version information for an instance
async function fetchVersionInfo(instanceUrl, instanceId) {
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
const versionCell = row.querySelector('td:nth-child(9)'); // Version column (adjusted after removing branch)
// Show loading state
if (versionCell) {
versionCell.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
}
try {
const apiKey = document.querySelector(`[data-instance-id="${instanceId}"]`).dataset.token;
if (!apiKey) {
throw new Error('No API key available');
}
console.log(`Getting JWT token for instance ${instanceId} for version info`);
const jwtToken = await getJWTToken(instanceUrl, apiKey);
console.log('Got JWT token for version info');
// Fetch version information
console.log(`Fetching version info for instance ${instanceId} from ${instanceUrl}/api/admin/version-info`);
const response = await fetch(`${instanceUrl}/api/admin/version-info`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${jwtToken}`
}
});
if (!response.ok) {
const errorText = await response.text();
console.error(`HTTP error ${response.status}:`, errorText);
throw new Error(`Server returned ${response.status}: ${errorText}`);
}
const data = await response.json();
console.log('Received version data:', data);
// Update version cell
if (versionCell) {
const appVersion = data.app_version || 'unknown';
const gitCommit = data.git_commit || 'unknown';
const deployedAt = data.deployed_at || 'unknown';
if (appVersion !== 'unknown') {
// Get the latest version for comparison
const latestVersionBadge = document.getElementById('latestVersionBadge');
let latestVersion = latestVersionBadge ? latestVersionBadge.textContent.replace('Loading...', '').trim() : null;
// If latest version is not available yet, wait a bit and try again
if (!latestVersion || latestVersion === '') {
// Show loading state while waiting for latest version
versionCell.innerHTML = `
<span class="badge bg-secondary version-badge" data-bs-toggle="tooltip"
title="App Version: ${appVersion}<br>Git Commit: ${gitCommit}<br>Deployed: ${deployedAt}<br>Waiting for latest version...">
<i class="fas fa-spinner fa-spin me-1"></i>${appVersion.length > 8 ? appVersion.substring(0, 8) : appVersion}
</span>`;
// Wait a bit and retry the comparison
setTimeout(() => {
fetchVersionInfo(instanceUrl, instanceId);
}, 2000);
return;
}
// Determine if this instance is up to date
let badgeClass = 'bg-secondary';
let statusIcon = 'fas fa-tag';
let tooltipText = `App Version: ${appVersion}<br>Git Commit: ${gitCommit}<br>Deployed: ${deployedAt}`;
if (latestVersion && appVersion === latestVersion) {
// Exact match - green
badgeClass = 'bg-success';
statusIcon = 'fas fa-check-circle';
tooltipText += '<br><strong>✅ Up to date</strong>';
} else if (latestVersion && appVersion !== latestVersion) {
// Compare semantic versions
const versionComparison = compareSemanticVersions(appVersion, latestVersion);
switch (versionComparison) {
case 'patch':
// Only patch version different - yellow
badgeClass = 'bg-warning';
statusIcon = 'fas fa-exclamation-triangle';
tooltipText += `<br><strong>🟡 Patch update available (Latest: ${latestVersion})</strong>`;
break;
case 'minor':
// Minor version different - orange
badgeClass = 'bg-orange';
statusIcon = 'fas fa-exclamation-triangle';
tooltipText += `<br><strong>🟠 Minor update available (Latest: ${latestVersion})</strong>`;
break;
case 'major':
// Major version different - red
badgeClass = 'bg-danger';
statusIcon = 'fas fa-exclamation-triangle';
tooltipText += `<br><strong>🔴 Major update available (Latest: ${latestVersion})</strong>`;
break;
default:
// Unknown format or comparison failed - red
badgeClass = 'bg-danger';
statusIcon = 'fas fa-exclamation-triangle';
tooltipText += `<br><strong>🔴 Outdated (Latest: ${latestVersion})</strong>`;
break;
}
}
versionCell.innerHTML = `
<span class="badge ${badgeClass} version-badge" data-bs-toggle="tooltip"
title="${tooltipText}">
<i class="${statusIcon} me-1"></i>${appVersion.length > 8 ? appVersion.substring(0, 8) : appVersion}
</span>`;
} else {
versionCell.innerHTML = '<span class="badge bg-secondary version-badge">unknown</span>';
}
}
// Update tooltips
const versionBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
if (versionBadge) {
new bootstrap.Tooltip(versionBadge);
}
} catch (error) {
console.error(`Error fetching version info for instance ${instanceId}:`, error);
// Show error state
if (versionCell) {
versionCell.innerHTML = `
<span class="text-warning" data-bs-toggle="tooltip" title="Error: ${error.message}">
<i class="fas fa-exclamation-triangle"></i> Error
</span>`;
}
// Add tooltips for error states
const errorBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
if (errorBadge) {
new bootstrap.Tooltip(errorBadge);
}
}
}
// Function to fetch company name from instance settings // Function to fetch company name from instance settings
async function fetchCompanyName(instanceUrl, instanceId) { async function fetchCompanyName(instanceUrl, instanceId) {
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr'); const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
@@ -975,53 +1217,30 @@ async function fetchCompanyName(instanceUrl, instanceId) {
// Function to fetch company names for all instances // Function to fetch company names for all instances
async function fetchCompanyNames() { async function fetchCompanyNames() {
console.log('Starting fetchCompanyNames...');
const instances = document.querySelectorAll('[data-instance-id]'); const instances = document.querySelectorAll('[data-instance-id]');
const loadingPromises = []; const loadingPromises = [];
console.log('Starting to fetch company names and stats for all instances'); for (const badge of instances) {
const instanceId = badge.dataset.instanceId;
const instanceUrl = badge.closest('tr').querySelector('td:nth-child(7) a')?.href;
const apiKey = badge.dataset.token;
for (const instance of instances) { if (instanceUrl && apiKey) {
const instanceId = instance.dataset.instanceId; console.log(`Fetching data for instance ${instanceId}`);
const row = instance.closest('tr'); loadingPromises.push(
fetchCompanyName(instanceUrl, instanceId),
// Debug: Log all cells in the row fetchVersionInfo(instanceUrl, instanceId) // Add version info fetching
console.log(`Row for instance ${instanceId}:`, { );
cells: Array.from(row.querySelectorAll('td')).map(td => ({
text: td.textContent.trim(),
html: td.innerHTML.trim()
}))
});
// Changed from nth-child(8) to nth-child(7) since Main URL is the 7th column
const urlCell = row.querySelector('td:nth-child(7)');
if (!urlCell) {
console.error(`Could not find URL cell for instance ${instanceId}`);
continue;
}
const urlLink = urlCell.querySelector('a');
if (!urlLink) {
console.error(`Could not find URL link for instance ${instanceId}`);
continue;
}
const instanceUrl = urlLink.getAttribute('href');
const token = instance.dataset.token;
console.log(`Instance ${instanceId}:`, {
url: instanceUrl,
hasToken: !!token
});
if (instanceUrl && token) {
loadingPromises.push(fetchCompanyName(instanceUrl, instanceId));
} else { } else {
const row = badge.closest('tr');
const cells = [ const cells = [
row.querySelector('td:nth-child(2)'), // Company row.querySelector('td:nth-child(2)'), // Company
row.querySelector('td:nth-child(3)'), // Rooms row.querySelector('td:nth-child(3)'), // Rooms
row.querySelector('td:nth-child(4)'), // Conversations row.querySelector('td:nth-child(4)'), // Conversations
row.querySelector('td:nth-child(5)') // Data row.querySelector('td:nth-child(5)'), // Data
row.querySelector('td:nth-child(9)') // Version
]; ];
cells.forEach(cell => { cells.forEach(cell => {
@@ -1041,7 +1260,7 @@ async function fetchCompanyNames() {
try { try {
await Promise.all(loadingPromises); await Promise.all(loadingPromises);
console.log('Finished fetching all company names and stats'); console.log('Finished fetching all company names, stats, and version info');
} catch (error) { } catch (error) {
console.error('Error in fetchCompanyNames:', error); console.error('Error in fetchCompanyNames:', error);
} }
@@ -1108,8 +1327,8 @@ async function editInstance(id) {
// Get the name from the first cell // Get the name from the first cell
const name = row.querySelector('td:first-child').textContent.trim(); const name = row.querySelector('td:first-child').textContent.trim();
// Get the main URL from the link in the URL cell (7th column) // Get the main URL from the link in the URL cell (9th column after adding Version and Branch)
const urlCell = row.querySelector('td:nth-child(7)'); const urlCell = row.querySelector('td:nth-child(9)');
const urlLink = urlCell.querySelector('a'); const urlLink = urlCell.querySelector('a');
const mainUrl = urlLink ? urlLink.getAttribute('href') : urlCell.textContent.trim(); const mainUrl = urlLink ? urlLink.getAttribute('href') : urlCell.textContent.trim();
@@ -1884,5 +2103,134 @@ function launchInstance() {
// Redirect to the launch progress page // Redirect to the launch progress page
window.location.href = '/instances/launch-progress'; window.location.href = '/instances/launch-progress';
} }
// Function to refresh all version information
async function refreshAllVersionInfo() {
console.log('Refreshing all version information...');
const instances = document.querySelectorAll('[data-instance-id]');
const loadingPromises = [];
for (const badge of instances) {
const instanceId = badge.dataset.instanceId;
const instanceUrl = badge.closest('tr').querySelector('td:nth-child(7) a')?.href;
const apiKey = badge.dataset.token;
if (instanceUrl && apiKey) {
console.log(`Refreshing version info for instance ${instanceId}`);
loadingPromises.push(fetchVersionInfo(instanceUrl, instanceId));
}
}
try {
await Promise.all(loadingPromises);
console.log('Finished refreshing all version information');
} catch (error) {
console.error('Error refreshing version information:', error);
}
}
// Function to fetch latest version information
async function fetchLatestVersion() {
console.log('Fetching latest version information...');
const versionBadge = document.getElementById('latestVersionBadge');
const commitSpan = document.getElementById('latestCommit');
const checkedSpan = document.getElementById('lastChecked');
// Show loading state
if (versionBadge) {
versionBadge.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Loading...';
versionBadge.className = 'badge bg-secondary fs-6';
}
try {
const response = await fetch('/api/latest-version', {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
}
});
if (!response.ok) {
const errorText = await response.text();
console.error(`HTTP error ${response.status}:`, errorText);
throw new Error(`Server returned ${response.status}: ${errorText}`);
}
const data = await response.json();
console.log('Received latest version data:', data);
if (data.success) {
// Update version badge
if (versionBadge) {
const version = data.latest_version;
if (version !== 'unknown') {
versionBadge.innerHTML = `<i class="fas fa-tag me-1"></i>${version}`;
versionBadge.className = 'badge bg-success fs-6';
} else {
versionBadge.innerHTML = '<i class="fas fa-question-circle me-1"></i>Unknown';
versionBadge.className = 'badge bg-warning fs-6';
}
}
// Update commit information
if (commitSpan) {
const commit = data.latest_commit;
if (commit !== 'unknown') {
commitSpan.textContent = commit.substring(0, 8);
commitSpan.title = commit;
} else {
commitSpan.textContent = 'Unknown';
}
}
// Update last checked time
if (checkedSpan) {
const lastChecked = data.last_checked;
if (lastChecked) {
const date = new Date(lastChecked);
checkedSpan.textContent = date.toLocaleString();
} else {
checkedSpan.textContent = 'Never';
}
}
} else {
// Handle error response
if (versionBadge) {
versionBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Error';
versionBadge.className = 'badge bg-danger fs-6';
}
if (commitSpan) {
commitSpan.textContent = 'Error';
}
if (checkedSpan) {
checkedSpan.textContent = 'Error';
}
console.error('Error in latest version response:', data.error);
}
} catch (error) {
console.error('Error fetching latest version:', error);
// Show error state
if (versionBadge) {
versionBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Error';
versionBadge.className = 'badge bg-danger fs-6';
}
if (commitSpan) {
commitSpan.textContent = 'Error';
}
if (checkedSpan) {
checkedSpan.textContent = 'Error';
}
}
}
// Function to refresh latest version (called by button)
async function refreshLatestVersion() {
console.log('Manual refresh of latest version requested');
await fetchLatestVersion();
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -297,6 +297,67 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Version Management -->
<div class="mb-5">
<h5 style="color: var(--primary-color);" class="mb-4">Version Management</h5>
<!-- Version Tracking -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white">
<h6 class="mb-0" style="color: var(--primary-color);">
<i class="fas fa-tags me-2"></i>Version Tracking
</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<h6 class="text-muted mb-2">Environment Variables</h6>
<ul class="list-unstyled small">
<li class="mb-1"><code>APP_VERSION</code> - Application version/tag</li>
<li class="mb-1"><code>GIT_COMMIT</code> - Git commit hash</li>
<li class="mb-1"><code>GIT_BRANCH</code> - Git branch name</li>
<li class="mb-1"><code>DEPLOYED_AT</code> - Deployment timestamp</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-2">Database Storage</h6>
<ul class="list-unstyled small">
<li class="mb-1">• Instance version tracking</li>
<li class="mb-1">• Version comparison</li>
<li class="mb-1">• Update notifications</li>
<li class="mb-1">• Deployment history</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Version API -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white">
<h6 class="mb-0" style="color: var(--primary-color);">
<i class="fas fa-code me-2"></i>Version API
</h6>
</div>
<div class="card-body">
<div class="mb-3">
<h6 class="text-muted mb-2">Endpoint</h6>
<code class="bg-light p-2 rounded d-block">GET /api/version</code>
</div>
<div class="mb-3">
<h6 class="text-muted mb-2">Response</h6>
<pre class="bg-light p-2 rounded small"><code>{
"version": "v1.2.3",
"tag": "v1.2.3",
"commit": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
"branch": "main",
"deployed_at": "2024-01-15T10:30:00.000000"
}</code></pre>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->

55
test_version_api.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
Test script for the new version API endpoint.
This verifies that the database-only version tracking works correctly.
"""
import os
import requests
import json
from datetime import datetime
def test_version_api():
"""Test the version API endpoint"""
# Set test environment variables
os.environ['APP_VERSION'] = 'v1.2.3'
os.environ['GIT_COMMIT'] = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0'
os.environ['GIT_BRANCH'] = 'main'
os.environ['DEPLOYED_AT'] = datetime.utcnow().isoformat()
print("Testing version API endpoint...")
print(f"APP_VERSION: {os.environ['APP_VERSION']}")
print(f"GIT_COMMIT: {os.environ['GIT_COMMIT']}")
print(f"GIT_BRANCH: {os.environ['GIT_BRANCH']}")
print(f"DEPLOYED_AT: {os.environ['DEPLOYED_AT']}")
try:
# Test the API endpoint (assuming it's running on localhost:5000)
response = requests.get('http://localhost:5000/api/version')
if response.status_code == 200:
data = response.json()
print("\n✅ Version API test successful!")
print("Response:")
print(json.dumps(data, indent=2))
# Verify the response matches our environment variables
assert data['version'] == os.environ['APP_VERSION'], f"Version mismatch: {data['version']} != {os.environ['APP_VERSION']}"
assert data['commit'] == os.environ['GIT_COMMIT'], f"Commit mismatch: {data['commit']} != {os.environ['GIT_COMMIT']}"
assert data['branch'] == os.environ['GIT_BRANCH'], f"Branch mismatch: {data['branch']} != {os.environ['GIT_BRANCH']}"
assert data['deployed_at'] == os.environ['DEPLOYED_AT'], f"Deployed at mismatch: {data['deployed_at']} != {os.environ['DEPLOYED_AT']}"
print("\n✅ All version information matches environment variables!")
else:
print(f"\n❌ Version API test failed with status code: {response.status_code}")
print(f"Response: {response.text}")
except requests.exceptions.ConnectionError:
print("\n❌ Could not connect to the API. Make sure the application is running on localhost:5000")
except Exception as e:
print(f"\n❌ Test failed with error: {str(e)}")
if __name__ == '__main__':
test_version_api()

View File

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