8 Commits
0.9 ... 0.10

Author SHA1 Message Date
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
14 changed files with 896 additions and 474 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...')
# 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('Admin credentials:')
print('Email: administrator@docupulse.com') print('Email: administrator@docupulse.com')
print('Password: changeme') 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

@@ -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({
'success': True,
'data': {
'name': stack['Name'], 'name': stack['Name'],
'id': stack['Id'], 'id': stack['Id'],
'status': 'existing' '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({
'success': True,
'data': {
'name': stack_info['Name'], 'name': stack_info['Name'],
'id': stack_info['Id'], 'id': stack_info['Id'],
'status': 'created' '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,6 +984,9 @@ 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"
current_app.logger.info(f"Checking services for stack {data['stack_name']} at endpoint {endpoint_id}")
try:
services_response = requests.get( services_response = requests.get(
services_url, services_url,
headers={ headers={
@@ -1019,10 +997,11 @@ def check_stack_status():
timeout=30 timeout=30
) )
if not services_response.ok: current_app.logger.info(f"Services API response status: {services_response.status_code}")
return jsonify({'error': 'Failed to get stack services'}), 500
if services_response.ok:
services = services_response.json() services = services_response.json()
current_app.logger.info(f"Found {len(services)} services for stack {data['stack_name']}")
# Check if all services are running # Check if all services are running
all_running = True all_running = True
@@ -1051,6 +1030,28 @@ def check_stack_status():
status = 'partial' status = 'partial'
else: else:
status = 'inactive' 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')
@@ -2019,32 +1983,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,15 +481,63 @@ 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) {
// 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'); throw new Error(stackResult.error || 'Failed to deploy stack');
} }
}
// Update the step to show success // Update the step to show success
const stackDeployStep = document.querySelectorAll('.step-item')[8]; const stackDeployStep = document.querySelectorAll('.step-item')[8];
@@ -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
@@ -1984,185 +2025,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 +2056,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 +2081,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 +2104,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 +2150,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 +2556,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,22 +5,41 @@
{% 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;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -65,6 +84,7 @@
<th>Main URL</th> <th>Main URL</th>
<th>Status</th> <th>Status</th>
<th>Version</th> <th>Version</th>
<th>Branch</th>
<th>Connection Token</th> <th>Connection Token</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -97,41 +117,23 @@
</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> </span>
{% else %} {% else %}
<span class="badge bg-warning badge-sm" data-bs-toggle="tooltip" title="Instance is outdated"> <span class="badge bg-secondary version-badge">unknown</span>
<i class="fas fa-exclamation-triangle"></i> Outdated
</span>
{% endif %} {% endif %}
</td>
<td>
{% if instance.deployed_branch %}
<span class="badge bg-light text-dark branch-badge" data-bs-toggle="tooltip"
title="Deployed branch: {{ instance.deployed_branch }}">
{{ instance.deployed_branch }}
</span>
{% else %} {% else %}
<span class="badge bg-secondary badge-sm" data-bs-toggle="tooltip" title="Latest version unknown"> <span class="badge bg-secondary branch-badge">unknown</span>
<i class="fas fa-question"></i> Unknown
</span>
{% endif %}
{% if instance.version_checked_at %}
<div class="small text-muted">
<i class="fas fa-clock"></i> {{ instance.version_checked_at.strftime('%H:%M') }}
</div>
{% endif %}
</div>
{% else %}
<span class="badge bg-secondary" data-bs-toggle="tooltip" title="Version information not available">
<i class="fas fa-question"></i> Unknown
</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@@ -861,19 +863,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;
@@ -992,8 +994,8 @@ async function fetchCompanyNames() {
})) }))
}); });
// Changed from nth-child(8) to nth-child(7) since Main URL is the 7th column // Main URL is now the 9th column (after adding Version and Branch columns)
const urlCell = row.querySelector('td:nth-child(7)'); const urlCell = row.querySelector('td:nth-child(9)');
if (!urlCell) { if (!urlCell) {
console.error(`Could not find URL cell for instance ${instanceId}`); console.error(`Could not find URL cell for instance ${instanceId}`);
@@ -1108,8 +1110,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();

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