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
- Node.js (version 18 or higher)
- npm or yarn
- Python 3.11 or higher
- PostgreSQL 13 or higher
- Docker and Docker Compose (for containerized deployment)
### Installation
@@ -23,18 +24,50 @@ cd docupulse
2. Install dependencies:
```bash
npm install
# or
yarn install
pip install -r requirements.txt
```
3. Start the development server:
3. Set up environment variables:
```bash
npm run dev
# or
yarn dev
# Copy example environment file
cp .env.example .env
# 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
- Document upload and management
@@ -42,6 +75,8 @@ yarn dev
- Secure document storage
- User authentication and authorization
- Document version control
- Multi-tenant instance management
- RESTful API
## Contributing

4
app.py
View File

@@ -36,6 +36,10 @@ def create_app():
app.config['CSS_VERSION'] = os.getenv('CSS_VERSION', '1.0.3') # Add CSS version for cache busting
app.config['SERVER_NAME'] = os.getenv('SERVER_NAME', '127.0.0.1:5000')
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
db.init_app(app)

View File

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

View File

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

View File

@@ -563,4 +563,31 @@ def generate_password_reset_token(current_user, user_id):
'reset_url': reset_url,
'expires_at': reset_token.expires_at.isoformat(),
'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
try:
# Try different authentication methods
headers = {
'Accept': 'application/json'
}
@@ -761,48 +760,6 @@ 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,
@@ -831,6 +788,9 @@ def deploy_stack():
if not portainer_settings:
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
auth_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/status",
@@ -872,6 +832,7 @@ def deploy_stack():
# Log the request 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 timeout: {stack_timeout} seconds")
# First, check if a stack with this name already exists
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
@@ -891,9 +852,12 @@ def deploy_stack():
if stack['Name'] == data['name']:
current_app.logger.info(f"Found existing stack: {stack['Name']} (ID: {stack['Id']})")
return jsonify({
'name': stack['Name'],
'id': stack['Id'],
'status': 'existing'
'success': True,
'data': {
'name': stack['Name'],
'id': stack['Id'],
'status': 'existing'
}
})
# If no existing stack found, proceed with creation
@@ -906,7 +870,7 @@ def deploy_stack():
# Add endpointId as a query parameter
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(
url,
headers={
@@ -916,7 +880,7 @@ def deploy_stack():
},
params=params,
json=request_body,
timeout=600 # 10 minutes timeout for stack creation
timeout=stack_timeout # Use configurable timeout
)
# Log the response details
@@ -936,15 +900,26 @@ def deploy_stack():
return jsonify({'error': f'Failed to create stack: {error_message}'}), 500
stack_info = create_response.json()
current_app.logger.info(f"Stack creation initiated: {stack_info['Name']} (ID: {stack_info['Id']})")
return jsonify({
'name': stack_info['Name'],
'id': stack_info['Id'],
'status': 'created'
'success': True,
'data': {
'name': stack_info['Name'],
'id': stack_info['Id'],
'status': 'creating'
}
})
except requests.exceptions.Timeout:
current_app.logger.error("Request timed out while deploying stack")
return jsonify({'error': 'Request timed out while deploying stack. The operation may still be in progress.'}), 504
current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack deployment")
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:
current_app.logger.error(f"Error deploying stack: {str(e)}")
return jsonify({'error': str(e)}), 500
@@ -1009,48 +984,74 @@ def check_stack_status():
# Get stack services to check their status
services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services"
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
)
if not services_response.ok:
return jsonify({'error': 'Failed to get stack services'}), 500
services = services_response.json()
current_app.logger.info(f"Checking services for stack {data['stack_name']} at endpoint {endpoint_id}")
# Check if all services are running
all_running = True
service_statuses = []
for service in services:
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0)
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
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'
}
service_statuses.append(service_status)
if replicas_actual < replicas_running:
all_running = False
try:
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
)
# Determine overall stack status
if all_running and len(services) > 0:
status = 'active'
elif len(services) > 0:
status = 'partial'
else:
status = 'inactive'
current_app.logger.info(f"Services API response status: {services_response.status_code}")
if services_response.ok:
services = services_response.json()
current_app.logger.info(f"Found {len(services)} services for stack {data['stack_name']}")
# Check if all services are running
all_running = True
service_statuses = []
for service in services:
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0)
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
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'
}
service_statuses.append(service_status)
if replicas_actual < replicas_running:
all_running = False
# Determine overall stack status
if all_running and len(services) > 0:
status = 'active'
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({
'success': True,
@@ -1060,7 +1061,9 @@ def check_stack_status():
'status': status,
'services': service_statuses,
'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:
# Construct the health check URL
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:
data = response.json()
@@ -386,46 +386,10 @@ def init_routes(main_bp):
gitea_repo = git_settings.get('repo') if git_settings else None
for instance in instances:
# 1. Check status
# 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()
@@ -661,6 +625,188 @@ def init_routes(main_bp):
'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'
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
@@ -2019,32 +2165,16 @@ def init_routes(main_bp):
@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'
}
# Get version information from environment variables
version = os.getenv('APP_VERSION', 'unknown')
commit = os.getenv('GIT_COMMIT', 'unknown')
branch = os.getenv('GIT_BRANCH', 'unknown')
deployed_at = os.getenv('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')
'tag': version,
'commit': commit,
'branch': branch,
'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;
}
.step-item.warning {
background-color: #fff3cd;
}
.step-icon {
width: 40px;
height: 40px;
@@ -72,6 +76,11 @@
color: white;
}
.step-item.warning .step-icon {
background-color: #ffc107;
color: white;
}
.step-content {
flex-grow: 1;
}
@@ -92,4 +101,8 @@
.step-item.failed .step-status {
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');
}
// 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
const dockerComposeStep = document.querySelectorAll('.step-item')[7];
dockerComposeStep.classList.remove('active');
@@ -476,14 +481,62 @@ async function startLaunch(data) {
// Step 9: Deploy 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({
step: 'Stack Deployment',
status: stackResult.success ? 'success' : 'error',
details: stackResult
});
// Handle different stack deployment scenarios
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
@@ -493,6 +546,8 @@ async function startLaunch(data) {
stackDeployStep.querySelector('.step-status').textContent =
stackResult.data.status === 'existing' ?
'Using existing stack' :
stackResult.data.status === 'active' ?
'Successfully deployed and activated stack' :
'Successfully deployed stack';
// Add stack details
@@ -517,13 +572,13 @@ async function startLaunch(data) {
</tr>
<tr>
<td>Stack ID</td>
<td>${stackResult.data.id}</td>
<td>${stackResult.data.id || 'Will be determined during deployment'}</td>
</tr>
<tr>
<td>Status</td>
<td>
<span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : 'success'}">
${stackResult.data.status === 'existing' ? 'Existing' : 'Deployed'}
<span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : stackResult.data.status === 'active' ? 'success' : 'warning'}">
${stackResult.data.status === 'existing' ? 'Existing' : stackResult.data.status === 'active' ? 'Active' : 'Deployed'}
</span>
</td>
</tr>
@@ -542,7 +597,7 @@ async function startLaunch(data) {
name: data.instanceName,
port: data.port,
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,
status: stackResult.data.status,
repository: data.repository,
@@ -571,40 +626,26 @@ async function startLaunch(data) {
saveDataStep.classList.add('completed');
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
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]}`);
if (!healthResult.success) {
@@ -612,7 +653,7 @@ async function startLaunch(data) {
}
// 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) {
const retryButton = document.createElement('button');
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...';
const retryResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
if (retryResult.success) {
healthStep.classList.remove('failed');
healthStep.classList.add('completed');
healthStepElement2.classList.remove('failed');
healthStepElement2.classList.add('completed');
retryButton.remove();
} else {
retryButton.disabled = false;
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
@@ -1973,7 +2014,9 @@ async function downloadDockerCompose(repo, branch) {
const result = await response.json();
return {
success: true,
content: result.content
content: result.content,
commit_hash: result.commit_hash,
latest_tag: result.latest_tag
};
} catch (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
async function saveInstanceData(instanceData) {
try {
@@ -2194,7 +2058,7 @@ async function saveInstanceData(instanceData) {
main_url: `https://${instanceData.domains[0]}`,
status: 'inactive',
port: instanceData.port,
stack_id: instanceData.stack_id,
stack_id: instanceData.stack_id || '', // Use empty string if null
stack_name: instanceData.stack_name,
repository: instanceData.repository,
branch: instanceData.branch
@@ -2219,7 +2083,7 @@ async function saveInstanceData(instanceData) {
main_url: `https://${instanceData.domains[0]}`,
status: 'inactive',
port: instanceData.port,
stack_id: instanceData.stack_id,
stack_id: instanceData.stack_id || '', // Use empty string if null
stack_name: instanceData.stack_name,
repository: instanceData.repository,
branch: instanceData.branch
@@ -2242,11 +2106,19 @@ async function saveInstanceData(instanceData) {
}
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;
const startTime = Date.now();
const maxTotalTime = 10 * 60 * 1000; // 10 minutes in milliseconds
while (currentAttempt <= maxRetries) {
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
const response = await fetch('/instances');
const text = await response.text();
@@ -2280,42 +2152,102 @@ async function checkInstanceHealth(instanceUrl) {
const data = await statusResponse.json();
// Update the health check step
const healthStep = document.querySelectorAll('.step-item')[8]; // Adjust index based on your steps
healthStep.classList.remove('active');
healthStep.classList.add('completed');
const statusText = healthStep.querySelector('.step-status');
const healthStepElement = document.querySelectorAll('.step-item')[10]; // Adjust index based on your steps
healthStepElement.classList.remove('active');
healthStepElement.classList.add('completed');
const statusText = healthStepElement.querySelector('.step-status');
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 {
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 {
throw new Error('Instance is not healthy');
}
} catch (error) {
console.error(`Health check attempt ${currentAttempt} failed:`, error);
// Update status to show current attempt
const healthStep = document.querySelectorAll('.step-item')[8];
const statusText = healthStep.querySelector('.step-status');
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}): ${error.message}`;
// Update status to show current attempt and elapsed time
const healthStepElement = document.querySelectorAll('.step-item')[10];
const statusText = healthStepElement.querySelector('.step-status');
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`;
if (currentAttempt === maxRetries) {
// 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)`;
}
return {
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
await new Promise(resolve => setTimeout(resolve, 5000));
// Wait before next attempt (5 seconds base delay)
await new Promise(resolve => setTimeout(resolve, baseDelay));
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) {
try {
// First check if instance is already authenticated
@@ -2625,4 +2557,290 @@ async function sendCompletionEmail(instanceUrl, company, credentials) {
error: error.message
};
}
}
// 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 %}
<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;
}
/* 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>
{% endblock %}
{% block content %}
{{ header(
title="Instances",
description="Manage your DocuPulse instances",
title="Instance Management",
description="Manage and monitor your DocuPulse instances",
icon="fa-server",
buttons=[
{
'text': 'Launch New Instance',
'url': '#',
'icon': 'fa-rocket',
'class': 'btn-primary',
'onclick': 'showAddInstanceModal()'
},
{
'text': 'Add Existing Instance',
'url': '#',
'icon': 'fa-link',
'class': 'btn-primary',
'onclick': 'showAddExistingInstanceModal()'
'onclick': 'showAddInstanceModal()',
'icon': 'fa-plus',
'class': 'btn-primary'
}
]
) }}
<!-- 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="row">
<div class="col-12">
@@ -97,41 +155,13 @@
</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
{% if instance.deployed_version %}
<span class="badge bg-info version-badge" data-bs-toggle="tooltip"
title="Deployed: {{ instance.deployed_version }}{% if instance.version_checked_at %}<br>Checked: {{ instance.version_checked_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}">
{{ instance.deployed_version[:8] if instance.deployed_version != 'unknown' else 'unknown' }}
</span>
{% else %}
<span class="badge bg-secondary version-badge">unknown</span>
{% endif %}
</td>
<td>
@@ -704,25 +734,37 @@ document.addEventListener('DOMContentLoaded', function() {
const headerButtons = document.querySelector('.header-buttons');
if (headerButtons) {
const refreshButton = document.createElement('button');
refreshButton.className = 'btn btn-outline-primary';
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh';
refreshButton.className = 'btn btn-outline-primary me-2';
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh All';
refreshButton.onclick = function() {
fetchCompanyNames();
};
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
setTimeout(() => {
// Check statuses on page load
checkAllInstanceStatuses();
setTimeout(async () => {
// First fetch latest version information
await fetchLatestVersion();
// Fetch company names for all instances
// Then check statuses and fetch company names
checkAllInstanceStatuses();
fetchCompanyNames();
}, 100);
// Set up periodic status checks (every 30 seconds)
setInterval(checkAllInstanceStatuses, 30000);
// Set up periodic latest version checks (every 5 minutes)
setInterval(fetchLatestVersion, 300000);
// Update color picker functionality
const primaryColor = document.getElementById('primaryColor');
@@ -766,12 +808,25 @@ document.addEventListener('DOMContentLoaded', function() {
updateColorPreview();
});
// Function to check status of all instances
// Function to check all instance statuses
async function checkAllInstanceStatuses() {
const statusBadges = document.querySelectorAll('[data-instance-id]');
for (const badge of statusBadges) {
console.log('Checking all instance statuses...');
const instances = document.querySelectorAll('[data-instance-id]');
for (const badge of instances) {
const instanceId = badge.dataset.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');
// Update rooms count
// Update rooms count (3rd column)
const roomsCell = row.querySelector('td:nth-child(3)');
if (roomsCell) {
roomsCell.textContent = data.rooms || '0';
}
// Update conversations count
// Update conversations count (4th column)
const conversationsCell = row.querySelector('td:nth-child(4)');
if (conversationsCell) {
conversationsCell.textContent = data.conversations || '0';
}
// Update data usage
// Update data usage (5th column)
const dataCell = row.querySelector('td:nth-child(5)');
if (dataCell) {
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
async function fetchCompanyName(instanceUrl, instanceId) {
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
async function fetchCompanyNames() {
console.log('Starting fetchCompanyNames...');
const instances = document.querySelectorAll('[data-instance-id]');
const loadingPromises = [];
console.log('Starting to fetch company names and stats for all instances');
for (const instance of instances) {
const instanceId = instance.dataset.instanceId;
const row = instance.closest('tr');
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;
// Debug: Log all cells in the row
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));
if (instanceUrl && apiKey) {
console.log(`Fetching data for instance ${instanceId}`);
loadingPromises.push(
fetchCompanyName(instanceUrl, instanceId),
fetchVersionInfo(instanceUrl, instanceId) // Add version info fetching
);
} else {
const row = badge.closest('tr');
const cells = [
row.querySelector('td:nth-child(2)'), // Company
row.querySelector('td:nth-child(3)'), // Rooms
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 => {
@@ -1041,7 +1260,7 @@ async function fetchCompanyNames() {
try {
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) {
console.error('Error in fetchCompanyNames:', error);
}
@@ -1108,8 +1327,8 @@ async function editInstance(id) {
// Get the name from the first cell
const name = row.querySelector('td:first-child').textContent.trim();
// Get the main URL from the link in the URL cell (7th column)
const urlCell = row.querySelector('td:nth-child(7)');
// 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(9)');
const urlLink = urlCell.querySelector('a');
const mainUrl = urlLink ? urlLink.getAttribute('href') : urlCell.textContent.trim();
@@ -1884,5 +2103,134 @@ function launchInstance() {
// Redirect to the launch progress page
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>
{% endblock %}

View File

@@ -297,6 +297,67 @@
</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>
<!-- 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"
}