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

@@ -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()
@@ -2019,32 +1983,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
@@ -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
async function saveInstanceData(instanceData) {
try {
@@ -2194,7 +2056,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 +2081,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 +2104,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 +2150,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 +2555,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,22 +5,41 @@
{% 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;
}
</style>
{% endblock %}
@@ -65,6 +84,7 @@
<th>Main URL</th>
<th>Status</th>
<th>Version</th>
<th>Branch</th>
<th>Connection Token</th>
<th>Actions</th>
</tr>
@@ -97,41 +117,23 @@
</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>
{% 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 %}
<span class="badge bg-secondary branch-badge">unknown</span>
{% endif %}
</td>
<td>
@@ -861,19 +863,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;
@@ -992,8 +994,8 @@ async function fetchCompanyNames() {
}))
});
// Changed from nth-child(8) to nth-child(7) since Main URL is the 7th column
const urlCell = row.querySelector('td:nth-child(7)');
// Main URL is now the 9th column (after adding Version and Branch columns)
const urlCell = row.querySelector('td:nth-child(9)');
if (!urlCell) {
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
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();

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