Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5168c27bf | |||
| 4cf9cca116 | |||
| af375a2b5c | |||
| 23a55e025c | |||
| 40b1a63cf5 | |||
| 033f82eb2b | |||
| 1370bef1f1 | |||
| 1a6741ec10 | |||
| 0b9005b481 | |||
| 7ec3027410 | |||
| 405cc83ba1 | |||
| 0bbdf0eaab |
53
README.md
53
README.md
@@ -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
4
app.py
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -563,4 +563,31 @@ def generate_password_reset_token(current_user, user_id):
|
||||
'reset_url': reset_url,
|
||||
'expires_at': reset_token.expires_at.isoformat(),
|
||||
'user_email': user.email
|
||||
})
|
||||
})
|
||||
|
||||
# Version Information
|
||||
@admin_api.route('/version-info', methods=['GET'])
|
||||
@csrf.exempt
|
||||
@token_required
|
||||
def get_version_info(current_user):
|
||||
"""Get version information from environment variables"""
|
||||
try:
|
||||
version_info = {
|
||||
'app_version': os.environ.get('APP_VERSION', 'unknown'),
|
||||
'git_commit': os.environ.get('GIT_COMMIT', 'unknown'),
|
||||
'git_branch': os.environ.get('GIT_BRANCH', 'unknown'),
|
||||
'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'),
|
||||
'ismaster': os.environ.get('ISMASTER', 'false'),
|
||||
'port': os.environ.get('PORT', 'unknown')
|
||||
}
|
||||
|
||||
return jsonify(version_info)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error getting version info: {str(e)}")
|
||||
return jsonify({
|
||||
'error': str(e),
|
||||
'app_version': 'unknown',
|
||||
'git_commit': 'unknown',
|
||||
'git_branch': 'unknown',
|
||||
'deployed_at': 'unknown'
|
||||
}), 500
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
256
routes/main.py
256
routes/main.py
@@ -350,7 +350,7 @@ def init_routes(main_bp):
|
||||
try:
|
||||
# Construct the health check URL
|
||||
health_url = f"{instance.main_url.rstrip('/')}/health"
|
||||
response = requests.get(health_url, timeout=5)
|
||||
response = requests.get(health_url, timeout=30) # Increased timeout to 30 seconds
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
@@ -386,46 +386,10 @@ def init_routes(main_bp):
|
||||
gitea_repo = git_settings.get('repo') if git_settings else None
|
||||
|
||||
for instance in instances:
|
||||
# 1. Check status
|
||||
# Check status
|
||||
status_info = check_instance_status(instance)
|
||||
instance.status = status_info['status']
|
||||
instance.status_details = status_info['details']
|
||||
|
||||
# 2. Check deployed version
|
||||
deployed_version = None
|
||||
deployed_tag = None
|
||||
deployed_commit = None
|
||||
try:
|
||||
version_url = f"{instance.main_url.rstrip('/')}/api/version"
|
||||
resp = requests.get(version_url, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
version_data = resp.json()
|
||||
deployed_version = version_data.get('version', 'unknown')
|
||||
deployed_tag = version_data.get('tag', 'unknown')
|
||||
deployed_commit = version_data.get('commit', 'unknown')
|
||||
except Exception as e:
|
||||
deployed_version = None
|
||||
deployed_tag = None
|
||||
deployed_commit = None
|
||||
|
||||
instance.deployed_version = deployed_tag or deployed_version or 'unknown'
|
||||
instance.deployed_branch = instance.deployed_branch or 'master'
|
||||
|
||||
# 3. Check latest version from Gitea (if settings available)
|
||||
latest_version = None
|
||||
deployed_branch = instance.deployed_branch or 'master'
|
||||
if gitea_url and gitea_token and gitea_repo:
|
||||
try:
|
||||
headers = {'Accept': 'application/json', 'Authorization': f'token {gitea_token}'}
|
||||
# Gitea API: /api/v1/repos/{owner}/{repo}/commits/{branch}
|
||||
commit_url = f"{gitea_url}/api/v1/repos/{gitea_repo}/commits/{deployed_branch}"
|
||||
commit_resp = requests.get(commit_url, headers=headers, timeout=5)
|
||||
if commit_resp.status_code == 200:
|
||||
latest_version = commit_resp.json().get('sha')
|
||||
except Exception as e:
|
||||
latest_version = None
|
||||
instance.latest_version = latest_version or 'unknown'
|
||||
instance.version_checked_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@@ -661,6 +625,188 @@ def init_routes(main_bp):
|
||||
'is_valid': is_valid
|
||||
})
|
||||
|
||||
@main_bp.route('/instances/<int:instance_id>/version-info')
|
||||
@login_required
|
||||
@require_password_change
|
||||
def instance_version_info(instance_id):
|
||||
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
instance = Instance.query.get_or_404(instance_id)
|
||||
|
||||
# Check if instance has a connection token
|
||||
if not instance.connection_token:
|
||||
return jsonify({
|
||||
'error': 'Instance not authenticated',
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch
|
||||
})
|
||||
|
||||
try:
|
||||
# Get JWT token using the connection token
|
||||
jwt_response = requests.post(
|
||||
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
||||
headers={
|
||||
'X-API-Key': instance.connection_token,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if jwt_response.status_code != 200:
|
||||
return jsonify({
|
||||
'error': 'Failed to authenticate with instance',
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch
|
||||
})
|
||||
|
||||
jwt_data = jwt_response.json()
|
||||
jwt_token = jwt_data.get('token')
|
||||
|
||||
if not jwt_token:
|
||||
return jsonify({
|
||||
'error': 'No JWT token received',
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch
|
||||
})
|
||||
|
||||
# Fetch version information from the instance
|
||||
response = requests.get(
|
||||
f"{instance.main_url.rstrip('/')}/api/admin/version-info",
|
||||
headers={
|
||||
'Authorization': f'Bearer {jwt_token}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
version_data = response.json()
|
||||
|
||||
# Update the instance with the fetched version information
|
||||
instance.deployed_version = version_data.get('app_version', instance.deployed_version)
|
||||
instance.deployed_branch = version_data.get('git_branch', instance.deployed_branch)
|
||||
instance.version_checked_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch,
|
||||
'git_commit': version_data.get('git_commit'),
|
||||
'deployed_at': version_data.get('deployed_at'),
|
||||
'version_checked_at': instance.version_checked_at.isoformat() if instance.version_checked_at else None
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'error': f'Failed to fetch version info: {response.status_code}',
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching version info: {str(e)}")
|
||||
return jsonify({
|
||||
'error': f'Error fetching version info: {str(e)}',
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch
|
||||
})
|
||||
|
||||
@main_bp.route('/api/latest-version')
|
||||
@login_required
|
||||
@require_password_change
|
||||
def get_latest_version():
|
||||
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
try:
|
||||
# Get Git settings
|
||||
git_settings = KeyValueSettings.get_value('git_settings')
|
||||
if not git_settings:
|
||||
return jsonify({
|
||||
'error': 'Git settings not configured',
|
||||
'latest_version': 'unknown',
|
||||
'latest_commit': 'unknown',
|
||||
'last_checked': None
|
||||
})
|
||||
|
||||
latest_tag = None
|
||||
latest_commit = None
|
||||
|
||||
if git_settings['provider'] == 'gitea':
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': f'token {git_settings["token"]}'
|
||||
}
|
||||
|
||||
# Get the latest tag
|
||||
tags_response = requests.get(
|
||||
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags',
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if tags_response.status_code == 200:
|
||||
tags_data = tags_response.json()
|
||||
if tags_data:
|
||||
# Sort tags by commit date (newest first) and get the latest
|
||||
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
||||
if sorted_tags:
|
||||
latest_tag = sorted_tags[0].get('name')
|
||||
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
||||
else:
|
||||
# Try token as query parameter if header auth fails
|
||||
tags_response = requests.get(
|
||||
f'{git_settings["url"]}/api/v1/repos/{git_settings["repo"]}/tags?token={git_settings["token"]}',
|
||||
headers={'Accept': 'application/json'},
|
||||
timeout=10
|
||||
)
|
||||
if tags_response.status_code == 200:
|
||||
tags_data = tags_response.json()
|
||||
if tags_data:
|
||||
sorted_tags = sorted(tags_data, key=lambda x: x.get('commit', {}).get('created', ''), reverse=True)
|
||||
if sorted_tags:
|
||||
latest_tag = sorted_tags[0].get('name')
|
||||
latest_commit = sorted_tags[0].get('commit', {}).get('id')
|
||||
|
||||
elif git_settings['provider'] == 'gitlab':
|
||||
headers = {
|
||||
'PRIVATE-TOKEN': git_settings['token'],
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
# Get the latest tag
|
||||
tags_response = requests.get(
|
||||
f'{git_settings["url"]}/api/v4/projects/{git_settings["repo"].replace("/", "%2F")}/repository/tags',
|
||||
headers=headers,
|
||||
params={'order_by': 'version', 'sort': 'desc', 'per_page': 1},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if tags_response.status_code == 200:
|
||||
tags_data = tags_response.json()
|
||||
if tags_data:
|
||||
latest_tag = tags_data[0].get('name')
|
||||
latest_commit = tags_data[0].get('commit', {}).get('id')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'latest_version': latest_tag or 'unknown',
|
||||
'latest_commit': latest_commit or 'unknown',
|
||||
'repository': git_settings.get('repo', 'unknown'),
|
||||
'provider': git_settings.get('provider', 'unknown'),
|
||||
'last_checked': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching latest version: {str(e)}")
|
||||
return jsonify({
|
||||
'error': f'Error fetching latest version: {str(e)}',
|
||||
'latest_version': 'unknown',
|
||||
'latest_commit': 'unknown',
|
||||
'last_checked': datetime.utcnow().isoformat()
|
||||
}), 500
|
||||
|
||||
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
||||
if not os.path.exists(UPLOAD_FOLDER):
|
||||
os.makedirs(UPLOAD_FOLDER)
|
||||
@@ -2019,32 +2165,16 @@ def init_routes(main_bp):
|
||||
|
||||
@main_bp.route('/api/version')
|
||||
def api_version():
|
||||
version_file = os.path.join(current_app.root_path, 'version.txt')
|
||||
version = 'unknown'
|
||||
version_data = {}
|
||||
|
||||
if os.path.exists(version_file):
|
||||
with open(version_file, 'r') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
# Try to parse as JSON first (new format)
|
||||
try:
|
||||
version_data = json.loads(content)
|
||||
version = version_data.get('tag', 'unknown')
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to old format (just commit hash)
|
||||
version = content
|
||||
version_data = {
|
||||
'tag': 'unknown',
|
||||
'commit': content,
|
||||
'branch': 'unknown',
|
||||
'deployed_at': 'unknown'
|
||||
}
|
||||
# Get version information from environment variables
|
||||
version = os.getenv('APP_VERSION', 'unknown')
|
||||
commit = os.getenv('GIT_COMMIT', 'unknown')
|
||||
branch = os.getenv('GIT_BRANCH', 'unknown')
|
||||
deployed_at = os.getenv('DEPLOYED_AT', 'unknown')
|
||||
|
||||
return jsonify({
|
||||
'version': version,
|
||||
'tag': version_data.get('tag', 'unknown'),
|
||||
'commit': version_data.get('commit', 'unknown'),
|
||||
'branch': version_data.get('branch', 'unknown'),
|
||||
'deployed_at': version_data.get('deployed_at', 'unknown')
|
||||
'tag': version,
|
||||
'commit': commit,
|
||||
'branch': branch,
|
||||
'deployed_at': deployed_at
|
||||
})
|
||||
59
set_version.py
Normal file
59
set_version.py
Normal 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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -451,6 +451,11 @@ async function startLaunch(data) {
|
||||
throw new Error(dockerComposeResult.error || 'Failed to download docker-compose.yml');
|
||||
}
|
||||
|
||||
// Set global version variables for deployment
|
||||
window.currentDeploymentVersion = dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown';
|
||||
window.currentDeploymentCommit = dockerComposeResult.commit_hash || 'unknown';
|
||||
window.currentDeploymentBranch = data.branch;
|
||||
|
||||
// Update the step to show success
|
||||
const dockerComposeStep = document.querySelectorAll('.step-item')[7];
|
||||
dockerComposeStep.classList.remove('active');
|
||||
@@ -476,14 +481,62 @@ async function startLaunch(data) {
|
||||
|
||||
// Step 9: Deploy Stack
|
||||
await updateStep(9, 'Deploying Stack', 'Launching your application stack...');
|
||||
const stackResult = await deployStack(dockerComposeResult.content, data.instanceName, data.port);
|
||||
|
||||
// Add progress indicator for stack deployment
|
||||
const stackDeployStepElement = document.querySelectorAll('.step-item')[8];
|
||||
const stackProgressDiv = document.createElement('div');
|
||||
stackProgressDiv.className = 'mt-2';
|
||||
stackProgressDiv.innerHTML = `
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
id="stackProgress">
|
||||
0%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" id="stackProgressText">Initiating stack deployment...</small>
|
||||
`;
|
||||
stackDeployStepElement.querySelector('.step-content').appendChild(stackProgressDiv);
|
||||
|
||||
const stackResult = await deployStack(dockerComposeResult.content, `docupulse_${data.port}`, data.port);
|
||||
launchReport.steps.push({
|
||||
step: 'Stack Deployment',
|
||||
status: stackResult.success ? 'success' : 'error',
|
||||
details: stackResult
|
||||
});
|
||||
|
||||
// Handle different stack deployment scenarios
|
||||
if (!stackResult.success) {
|
||||
throw new Error(stackResult.error || 'Failed to deploy stack');
|
||||
// Check if this is a timeout but the stack might still be deploying
|
||||
if (stackResult.error && stackResult.error.includes('timed out')) {
|
||||
console.log('Stack deployment timed out, but may still be in progress');
|
||||
|
||||
// Update the step to show warning instead of error
|
||||
const stackDeployStep = document.querySelectorAll('.step-item')[8];
|
||||
stackDeployStep.classList.remove('active');
|
||||
stackDeployStep.classList.add('warning');
|
||||
stackDeployStep.querySelector('.step-status').textContent = 'Stack deployment timed out but may still be in progress';
|
||||
|
||||
// Add a note about the timeout
|
||||
const timeoutNote = document.createElement('div');
|
||||
timeoutNote.className = 'alert alert-warning mt-2';
|
||||
timeoutNote.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Note:</strong> The stack deployment request timed out, but the deployment may still be in progress.
|
||||
You can check the status in your Portainer dashboard or wait a few minutes and refresh this page.
|
||||
`;
|
||||
stackDeployStep.querySelector('.step-content').appendChild(timeoutNote);
|
||||
|
||||
// Continue with the process using the available data
|
||||
stackResult.data = stackResult.data || {
|
||||
name: `docupulse_${data.port}`,
|
||||
status: 'creating',
|
||||
id: null
|
||||
};
|
||||
} else {
|
||||
throw new Error(stackResult.error || 'Failed to deploy stack');
|
||||
}
|
||||
}
|
||||
|
||||
// Update the step to show success
|
||||
@@ -493,6 +546,8 @@ async function startLaunch(data) {
|
||||
stackDeployStep.querySelector('.step-status').textContent =
|
||||
stackResult.data.status === 'existing' ?
|
||||
'Using existing stack' :
|
||||
stackResult.data.status === 'active' ?
|
||||
'Successfully deployed and activated stack' :
|
||||
'Successfully deployed stack';
|
||||
|
||||
// Add stack details
|
||||
@@ -517,13 +572,13 @@ async function startLaunch(data) {
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stack ID</td>
|
||||
<td>${stackResult.data.id}</td>
|
||||
<td>${stackResult.data.id || 'Will be determined during deployment'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : 'success'}">
|
||||
${stackResult.data.status === 'existing' ? 'Existing' : 'Deployed'}
|
||||
<span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : stackResult.data.status === 'active' ? 'success' : 'warning'}">
|
||||
${stackResult.data.status === 'existing' ? 'Existing' : stackResult.data.status === 'active' ? 'Active' : 'Deployed'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -542,7 +597,7 @@ async function startLaunch(data) {
|
||||
name: data.instanceName,
|
||||
port: data.port,
|
||||
domains: data.webAddresses,
|
||||
stack_id: stackResult.data.id,
|
||||
stack_id: stackResult.data.id || null, // May be null if we got a 504
|
||||
stack_name: stackResult.data.name,
|
||||
status: stackResult.data.status,
|
||||
repository: data.repository,
|
||||
@@ -571,40 +626,26 @@ async function startLaunch(data) {
|
||||
saveDataStep.classList.add('completed');
|
||||
saveDataStep.querySelector('.step-status').textContent = 'Successfully saved instance data';
|
||||
|
||||
// Add instance details
|
||||
const instanceDetails = document.createElement('div');
|
||||
instanceDetails.className = 'mt-3';
|
||||
instanceDetails.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3">Instance Information</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Property</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Internal Port</td>
|
||||
<td>${data.port}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Domains</td>
|
||||
<td><a href="https://${data.webAddresses.join(', ')}" target="_blank">${data.webAddresses.join(', ')}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
saveDataStep.querySelector('.step-content').appendChild(instanceDetails);
|
||||
|
||||
// After saving instance data, add the health check step
|
||||
await updateStep(11, 'Health Check', 'Verifying instance health...');
|
||||
|
||||
// Add a progress indicator
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[10];
|
||||
const progressDiv = document.createElement('div');
|
||||
progressDiv.className = 'mt-2';
|
||||
progressDiv.innerHTML = `
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
id="healthProgress">
|
||||
0%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" id="healthProgressText">Starting health check...</small>
|
||||
`;
|
||||
healthStepElement.querySelector('.step-content').appendChild(progressDiv);
|
||||
|
||||
const healthResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
|
||||
|
||||
if (!healthResult.success) {
|
||||
@@ -612,7 +653,7 @@ async function startLaunch(data) {
|
||||
}
|
||||
|
||||
// Add a retry button if health check fails
|
||||
const healthStep = document.querySelectorAll('.step-item')[10];
|
||||
const healthStepElement2 = document.querySelectorAll('.step-item')[10];
|
||||
if (!healthResult.success) {
|
||||
const retryButton = document.createElement('button');
|
||||
retryButton.className = 'btn btn-sm btn-warning mt-2';
|
||||
@@ -622,15 +663,15 @@ async function startLaunch(data) {
|
||||
retryButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Checking...';
|
||||
const retryResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
|
||||
if (retryResult.success) {
|
||||
healthStep.classList.remove('failed');
|
||||
healthStep.classList.add('completed');
|
||||
healthStepElement2.classList.remove('failed');
|
||||
healthStepElement2.classList.add('completed');
|
||||
retryButton.remove();
|
||||
} else {
|
||||
retryButton.disabled = false;
|
||||
retryButton.innerHTML = '<i class="fas fa-sync-alt me-1"></i> Retry Health Check';
|
||||
}
|
||||
};
|
||||
healthStep.querySelector('.step-content').appendChild(retryButton);
|
||||
healthStepElement2.querySelector('.step-content').appendChild(retryButton);
|
||||
}
|
||||
|
||||
// After health check, add authentication step
|
||||
@@ -1973,7 +2014,9 @@ async function downloadDockerCompose(repo, branch) {
|
||||
const result = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
content: result.content
|
||||
content: result.content,
|
||||
commit_hash: result.commit_hash,
|
||||
latest_tag: result.latest_tag
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error downloading docker-compose.yml:', error);
|
||||
@@ -1984,185 +2027,6 @@ async function downloadDockerCompose(repo, branch) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add new function to deploy stack
|
||||
async function deployStack(dockerComposeContent, stackName, port) {
|
||||
const maxRetries = 30; // 30 retries * 10 seconds = 5 minutes
|
||||
const baseDelay = 10000; // 10 seconds base delay
|
||||
|
||||
try {
|
||||
// First, attempt to deploy the stack
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10 * 60 * 1000); // 10 minutes timeout
|
||||
|
||||
const response = await fetch('/api/admin/deploy-stack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `docupulse_${port}`,
|
||||
StackFileContent: dockerComposeContent,
|
||||
Env: [
|
||||
{
|
||||
name: 'PORT',
|
||||
value: port.toString()
|
||||
},
|
||||
{
|
||||
name: 'ISMASTER',
|
||||
value: 'false'
|
||||
}
|
||||
]
|
||||
}),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId); // Clear the timeout if the request completes
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to deploy stack');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// If deployment was successful, wait for stack to come online
|
||||
if (result.success || result.data) {
|
||||
console.log('Stack deployment initiated, waiting for stack to come online...');
|
||||
|
||||
// Update status to show we're waiting for stack to come online
|
||||
const currentStep = document.querySelector('.step-item.active');
|
||||
if (currentStep) {
|
||||
const statusElement = currentStep.querySelector('.step-status');
|
||||
statusElement.textContent = 'Stack deployed, waiting for services to start...';
|
||||
}
|
||||
|
||||
// Wait and retry to check if stack is online
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Check stack status via Portainer API
|
||||
const stackCheckResponse = await fetch('/api/admin/check-stack-status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stack_name: `docupulse_${port}`
|
||||
})
|
||||
});
|
||||
|
||||
if (stackCheckResponse.ok) {
|
||||
const stackStatus = await stackCheckResponse.json();
|
||||
|
||||
if (stackStatus.success && stackStatus.data.status === 'active') {
|
||||
console.log(`Stack came online successfully on attempt ${attempt}`);
|
||||
|
||||
// Update status to show success
|
||||
if (currentStep) {
|
||||
const statusElement = currentStep.querySelector('.step-status');
|
||||
statusElement.textContent = 'Stack deployed and online successfully';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data || result,
|
||||
status: 'active',
|
||||
attempt: attempt
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If not online yet and this isn't the last attempt, wait and retry
|
||||
if (attempt < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(1.2, attempt - 1); // Exponential backoff
|
||||
|
||||
console.log(`Attempt ${attempt}/${maxRetries}: Stack not yet online. Waiting ${Math.round(delay/1000)}s before retry...`);
|
||||
|
||||
// Update the step description to show retry progress
|
||||
if (currentStep) {
|
||||
const statusElement = currentStep.querySelector('.step-status');
|
||||
statusElement.textContent = `Waiting for stack to come online... (Attempt ${attempt}/${maxRetries})`;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
} else {
|
||||
// Last attempt failed - stack might be online but API check failed
|
||||
console.log(`Stack status check failed after ${maxRetries} attempts, but deployment was successful`);
|
||||
|
||||
// Update status to show partial success
|
||||
if (currentStep) {
|
||||
const statusElement = currentStep.querySelector('.step-status');
|
||||
statusElement.textContent = 'Stack deployed (status check timeout)';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data || result,
|
||||
status: 'deployed',
|
||||
note: 'Status check timeout - stack may be online'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Stack status check attempt ${attempt} failed:`, error);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
// Last attempt failed, but deployment was successful
|
||||
console.log('Stack status check failed after all attempts, but deployment was successful');
|
||||
|
||||
if (currentStep) {
|
||||
const statusElement = currentStep.querySelector('.step-status');
|
||||
statusElement.textContent = 'Stack deployed (status check failed)';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data || result,
|
||||
status: 'deployed',
|
||||
note: 'Status check failed - stack may be online'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Wait before retrying on error
|
||||
const delay = baseDelay * Math.pow(1.2, attempt - 1);
|
||||
console.log(`Stack check failed, retrying in ${Math.round(delay/1000)}s...`);
|
||||
|
||||
if (currentStep) {
|
||||
const statusElement = currentStep.querySelector('.step-status');
|
||||
statusElement.textContent = `Stack check failed, retrying... (Attempt ${attempt}/${maxRetries})`;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, deployment was successful but we couldn't verify status
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...result.data || result,
|
||||
status: 'deployed',
|
||||
note: 'Deployment successful, status unknown'
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deploying stack:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add new function to save instance data
|
||||
async function saveInstanceData(instanceData) {
|
||||
try {
|
||||
@@ -2194,7 +2058,7 @@ async function saveInstanceData(instanceData) {
|
||||
main_url: `https://${instanceData.domains[0]}`,
|
||||
status: 'inactive',
|
||||
port: instanceData.port,
|
||||
stack_id: instanceData.stack_id,
|
||||
stack_id: instanceData.stack_id || '', // Use empty string if null
|
||||
stack_name: instanceData.stack_name,
|
||||
repository: instanceData.repository,
|
||||
branch: instanceData.branch
|
||||
@@ -2219,7 +2083,7 @@ async function saveInstanceData(instanceData) {
|
||||
main_url: `https://${instanceData.domains[0]}`,
|
||||
status: 'inactive',
|
||||
port: instanceData.port,
|
||||
stack_id: instanceData.stack_id,
|
||||
stack_id: instanceData.stack_id || '', // Use empty string if null
|
||||
stack_name: instanceData.stack_name,
|
||||
repository: instanceData.repository,
|
||||
branch: instanceData.branch
|
||||
@@ -2242,11 +2106,19 @@ async function saveInstanceData(instanceData) {
|
||||
}
|
||||
|
||||
async function checkInstanceHealth(instanceUrl) {
|
||||
const maxRetries = 5;
|
||||
const maxRetries = 120; // 120 retries * 5 seconds = 10 minutes total
|
||||
const baseDelay = 5000; // 5 seconds base delay
|
||||
let currentAttempt = 1;
|
||||
const startTime = Date.now();
|
||||
const maxTotalTime = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||
|
||||
while (currentAttempt <= maxRetries) {
|
||||
try {
|
||||
// Check if we've exceeded the total timeout
|
||||
if (Date.now() - startTime > maxTotalTime) {
|
||||
throw new Error('Health check timeout: 10 minutes exceeded');
|
||||
}
|
||||
|
||||
// First get the instance ID from the database
|
||||
const response = await fetch('/instances');
|
||||
const text = await response.text();
|
||||
@@ -2280,42 +2152,102 @@ async function checkInstanceHealth(instanceUrl) {
|
||||
const data = await statusResponse.json();
|
||||
|
||||
// Update the health check step
|
||||
const healthStep = document.querySelectorAll('.step-item')[8]; // Adjust index based on your steps
|
||||
healthStep.classList.remove('active');
|
||||
healthStep.classList.add('completed');
|
||||
const statusText = healthStep.querySelector('.step-status');
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[10]; // Adjust index based on your steps
|
||||
healthStepElement.classList.remove('active');
|
||||
healthStepElement.classList.add('completed');
|
||||
const statusText = healthStepElement.querySelector('.step-status');
|
||||
|
||||
if (data.status === 'active') {
|
||||
statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries})`;
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed)`;
|
||||
|
||||
// Update progress bar to 100%
|
||||
const progressBar = document.getElementById('healthProgress');
|
||||
const progressText = document.getElementById('healthProgressText');
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-success');
|
||||
progressText.textContent = `Health check completed successfully in ${elapsedTime}s`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data
|
||||
data: data,
|
||||
attempts: currentAttempt,
|
||||
elapsedTime: elapsedTime
|
||||
};
|
||||
} else if (data.status === 'inactive') {
|
||||
console.log(`Stack ${stackName} is inactive, continuing to poll...`);
|
||||
lastKnownStatus = 'inactive';
|
||||
if (progressText) {
|
||||
progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
} else if (data.status === 'starting') {
|
||||
console.log(`Stack ${stackName} is starting up, continuing to poll...`);
|
||||
lastKnownStatus = 'starting';
|
||||
if (progressText) {
|
||||
progressText.textContent = `Stack is initializing (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
} else {
|
||||
throw new Error('Instance is not healthy');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Health check attempt ${currentAttempt} failed:`, error);
|
||||
|
||||
// Update status to show current attempt
|
||||
const healthStep = document.querySelectorAll('.step-item')[8];
|
||||
const statusText = healthStep.querySelector('.step-status');
|
||||
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}): ${error.message}`;
|
||||
// Update status to show current attempt and elapsed time
|
||||
const healthStepElement = document.querySelectorAll('.step-item')[10];
|
||||
const statusText = healthStepElement.querySelector('.step-status');
|
||||
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
||||
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`;
|
||||
|
||||
if (currentAttempt === maxRetries) {
|
||||
// Update progress bar
|
||||
const progressBar = document.getElementById('healthProgress');
|
||||
const progressText = document.getElementById('healthProgressText');
|
||||
if (progressBar && progressText) {
|
||||
const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100);
|
||||
progressBar.style.width = `${progressPercent}%`;
|
||||
progressBar.textContent = `${Math.round(progressPercent)}%`;
|
||||
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
|
||||
}
|
||||
|
||||
if (currentAttempt === maxRetries || (Date.now() - startTime > maxTotalTime)) {
|
||||
// Update progress bar to show failure
|
||||
if (progressBar && progressText) {
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-danger');
|
||||
progressText.textContent = `Health check failed after ${currentAttempt} attempts (${elapsedTime}s)`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Health check failed after ${maxRetries} attempts: ${error.message}`
|
||||
error: `Health check failed after ${currentAttempt} attempts (${elapsedTime}s): ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
// Wait 5 seconds before next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
// Wait before next attempt (5 seconds base delay)
|
||||
await new Promise(resolve => setTimeout(resolve, baseDelay));
|
||||
currentAttempt++;
|
||||
|
||||
// Update progress bar in real-time
|
||||
updateHealthProgress(currentAttempt, maxRetries, elapsedTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateHealthProgress(currentAttempt, maxRetries, elapsedTime) {
|
||||
const progressBar = document.getElementById('healthProgress');
|
||||
const progressText = document.getElementById('healthProgressText');
|
||||
|
||||
if (progressBar && progressText) {
|
||||
const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100);
|
||||
progressBar.style.width = `${progressPercent}%`;
|
||||
progressBar.textContent = `${Math.round(progressPercent)}%`;
|
||||
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticateInstance(instanceUrl, instanceId) {
|
||||
try {
|
||||
// First check if instance is already authenticated
|
||||
@@ -2625,4 +2557,290 @@ async function sendCompletionEmail(instanceUrl, company, credentials) {
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add new function to check if stack exists
|
||||
async function checkStackExists(stackName) {
|
||||
try {
|
||||
const response = await fetch('/api/admin/check-stack-status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stack_name: stackName
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return {
|
||||
exists: true,
|
||||
status: result.data?.status || 'unknown',
|
||||
data: result.data
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
exists: false,
|
||||
status: 'not_found'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking stack existence:', error);
|
||||
return {
|
||||
exists: false,
|
||||
status: 'error',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add new function to deploy stack
|
||||
async function deployStack(dockerComposeContent, stackName, port) {
|
||||
try {
|
||||
// First, attempt to deploy the stack
|
||||
const response = await fetch('/api/admin/deploy-stack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `docupulse_${port}`,
|
||||
StackFileContent: dockerComposeContent,
|
||||
Env: [
|
||||
{
|
||||
name: 'PORT',
|
||||
value: port.toString()
|
||||
},
|
||||
{
|
||||
name: 'ISMASTER',
|
||||
value: 'false'
|
||||
},
|
||||
{
|
||||
name: 'APP_VERSION',
|
||||
value: window.currentDeploymentVersion || 'unknown'
|
||||
},
|
||||
{
|
||||
name: 'GIT_COMMIT',
|
||||
value: window.currentDeploymentCommit || 'unknown'
|
||||
},
|
||||
{
|
||||
name: 'GIT_BRANCH',
|
||||
value: window.currentDeploymentBranch || 'unknown'
|
||||
},
|
||||
{
|
||||
name: 'DEPLOYED_AT',
|
||||
value: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
// Handle 504 Gateway Timeout as successful initiation
|
||||
if (response.status === 504) {
|
||||
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
||||
|
||||
// Update progress to show that we're now polling
|
||||
const progressBar = document.getElementById('stackProgress');
|
||||
const progressText = document.getElementById('stackProgressText');
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '25%';
|
||||
progressBar.textContent = '25%';
|
||||
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
|
||||
}
|
||||
|
||||
// Start polling immediately since the stack creation was initiated
|
||||
console.log('Starting to poll for stack status after 504 timeout...');
|
||||
const pollResult = await pollStackStatus(`docupulse_${port}`, 15 * 60 * 1000); // 15 minutes max
|
||||
return pollResult;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to deploy stack');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Stack deployment initiated:', result);
|
||||
|
||||
// If stack is being created, poll for status
|
||||
if (result.data.status === 'creating') {
|
||||
console.log('Stack is being created, polling for status...');
|
||||
const pollResult = await pollStackStatus(`docupulse_${port}`, 10 * 60 * 1000); // 10 minutes max
|
||||
return pollResult;
|
||||
}
|
||||
|
||||
// Return success result with response data
|
||||
return {
|
||||
success: true,
|
||||
data: result.data
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deploying stack:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Function to poll stack status
|
||||
async function pollStackStatus(stackName, maxWaitTime = 15 * 60 * 1000) {
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 5000; // 5 seconds
|
||||
let attempts = 0;
|
||||
let lastKnownStatus = 'unknown';
|
||||
|
||||
// Validate stack name
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
console.error('Invalid stack name provided to pollStackStatus:', stackName);
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid stack name: ${stackName}`,
|
||||
data: {
|
||||
name: stackName,
|
||||
status: 'error'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Starting to poll stack status for: ${stackName} (max wait: ${maxWaitTime / 1000}s)`);
|
||||
|
||||
// Update progress indicator
|
||||
const progressBar = document.getElementById('stackProgress');
|
||||
const progressText = document.getElementById('stackProgressText');
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
attempts++;
|
||||
console.log(`Polling attempt ${attempts} for stack: ${stackName}`);
|
||||
|
||||
// Update progress - start at 25% if we came from a 504 timeout, otherwise start at 0%
|
||||
const elapsed = Date.now() - startTime;
|
||||
const baseProgress = progressBar && progressBar.style.width === '25%' ? 25 : 0;
|
||||
const progress = Math.min(baseProgress + (elapsed / maxWaitTime) * 70, 95); // Cap at 95% until complete
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.textContent = `${Math.round(progress)}%`;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
stack_name: stackName
|
||||
};
|
||||
console.log(`Sending stack status check request:`, requestBody);
|
||||
|
||||
const response = await fetch('/api/admin/check-stack-status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
timeout: 30000 // 30 second timeout for status checks
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log(`Stack status check result:`, result);
|
||||
|
||||
if (result.data && result.data.status === 'active') {
|
||||
console.log(`Stack ${stackName} is now active!`);
|
||||
// Update progress to 100%
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-success');
|
||||
progressText.textContent = 'Stack is now active and running!';
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name: stackName,
|
||||
id: result.data.stack_id,
|
||||
status: 'active'
|
||||
}
|
||||
};
|
||||
} else if (result.data && result.data.status === 'partial') {
|
||||
console.log(`Stack ${stackName} is partially running, continuing to poll...`);
|
||||
lastKnownStatus = 'partial';
|
||||
if (progressText) {
|
||||
progressText.textContent = `Stack is partially running (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
} else if (result.data && result.data.status === 'inactive') {
|
||||
console.log(`Stack ${stackName} is inactive, continuing to poll...`);
|
||||
lastKnownStatus = 'inactive';
|
||||
if (progressText) {
|
||||
progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
} else if (result.data && result.data.status === 'starting') {
|
||||
console.log(`Stack ${stackName} exists and is starting up - continuing to next step`);
|
||||
// Stack exists, we can continue - no need to wait for all services
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.textContent = '100%';
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-success');
|
||||
progressText.textContent = 'Stack created successfully!';
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name: stackName,
|
||||
id: result.data.stack_id,
|
||||
status: 'starting'
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.log(`Stack ${stackName} status unknown, continuing to poll...`);
|
||||
lastKnownStatus = 'unknown';
|
||||
if (progressText) {
|
||||
progressText.textContent = `Checking stack status (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
console.log(`Stack ${stackName} not found yet, continuing to poll...`);
|
||||
lastKnownStatus = 'not_found';
|
||||
if (progressText) {
|
||||
progressText.textContent = `Stack not found yet (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
} else {
|
||||
console.log(`Stack status check failed with status ${response.status}, continuing to poll...`);
|
||||
if (progressText) {
|
||||
progressText.textContent = `Status check failed (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error polling stack status (attempt ${attempts}):`, error);
|
||||
if (progressText) {
|
||||
progressText.textContent = `Error checking status (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// If we get here, we've timed out
|
||||
console.error(`Stack status polling timed out after ${maxWaitTime / 1000} seconds`);
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-warning');
|
||||
progressText.textContent = `Stack deployment timed out after ${Math.round(maxWaitTime / 1000)}s. Last known status: ${lastKnownStatus}`;
|
||||
}
|
||||
|
||||
// Return a more informative error message
|
||||
const statusMessage = lastKnownStatus !== 'unknown' ? ` (last known status: ${lastKnownStatus})` : '';
|
||||
return {
|
||||
success: false,
|
||||
error: `Stack deployment timed out after ${Math.round(maxWaitTime / 1000)} seconds${statusMessage}. The stack may still be deploying in the background.`,
|
||||
data: {
|
||||
name: stackName,
|
||||
status: lastKnownStatus
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,48 +5,106 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.version-info {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.version-info code {
|
||||
font-size: 0.8rem;
|
||||
background-color: #f8f9fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.badge-sm {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Version column styling */
|
||||
.version-badge {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.branch-badge {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Make table responsive */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Tooltip styling for version info */
|
||||
.tooltip-inner {
|
||||
max-width: 300px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Version comparison styling */
|
||||
.version-outdated {
|
||||
background-color: #fff3cd !important;
|
||||
border-color: #ffeaa7 !important;
|
||||
}
|
||||
|
||||
.version-current {
|
||||
background-color: #d1ecf1 !important;
|
||||
border-color: #bee5eb !important;
|
||||
}
|
||||
|
||||
.badge.bg-orange {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.badge.bg-orange:hover {
|
||||
background-color: #e55a00 !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ header(
|
||||
title="Instances",
|
||||
description="Manage your DocuPulse instances",
|
||||
title="Instance Management",
|
||||
description="Manage and monitor your DocuPulse instances",
|
||||
icon="fa-server",
|
||||
buttons=[
|
||||
{
|
||||
'text': 'Launch New Instance',
|
||||
'url': '#',
|
||||
'icon': 'fa-rocket',
|
||||
'class': 'btn-primary',
|
||||
'onclick': 'showAddInstanceModal()'
|
||||
},
|
||||
{
|
||||
'text': 'Add Existing Instance',
|
||||
'url': '#',
|
||||
'icon': 'fa-link',
|
||||
'class': 'btn-primary',
|
||||
'onclick': 'showAddExistingInstanceModal()'
|
||||
'onclick': 'showAddInstanceModal()',
|
||||
'icon': 'fa-plus',
|
||||
'class': 'btn-primary'
|
||||
}
|
||||
]
|
||||
) }}
|
||||
|
||||
<!-- Latest Version Information -->
|
||||
<div class="container-fluid mb-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title mb-2">
|
||||
<i class="fas fa-code-branch me-2" style="color: var(--primary-color);"></i>
|
||||
Latest Available Version
|
||||
</h5>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-4">
|
||||
<span class="badge bg-success fs-6" id="latestVersionBadge">
|
||||
<i class="fas fa-spinner fa-spin me-1"></i> Loading...
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-clock me-1"></i>
|
||||
Last checked: <span id="lastChecked" class="text-muted">Loading...</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="refreshLatestVersion()">
|
||||
<i class="fas fa-sync-alt me-1"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
@@ -97,41 +155,13 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if instance.deployed_version and instance.deployed_version != 'unknown' %}
|
||||
<div class="version-info">
|
||||
<div class="small">
|
||||
<strong>Version:</strong>
|
||||
<code class="text-primary">{{ instance.deployed_version }}</code>
|
||||
</div>
|
||||
{% if instance.latest_version and instance.latest_version != 'unknown' %}
|
||||
<div class="small">
|
||||
<strong>Latest:</strong>
|
||||
<code class="text-secondary">{{ instance.latest_version }}</code>
|
||||
</div>
|
||||
{% if instance.deployed_version == instance.latest_version %}
|
||||
<span class="badge bg-success badge-sm" data-bs-toggle="tooltip" title="Instance is up to date">
|
||||
<i class="fas fa-check"></i> Up-to-date
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning badge-sm" data-bs-toggle="tooltip" title="Instance is outdated">
|
||||
<i class="fas fa-exclamation-triangle"></i> Outdated
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary badge-sm" data-bs-toggle="tooltip" title="Latest version unknown">
|
||||
<i class="fas fa-question"></i> Unknown
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if instance.version_checked_at %}
|
||||
<div class="small text-muted">
|
||||
<i class="fas fa-clock"></i> {{ instance.version_checked_at.strftime('%H:%M') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary" data-bs-toggle="tooltip" title="Version information not available">
|
||||
<i class="fas fa-question"></i> Unknown
|
||||
{% if instance.deployed_version %}
|
||||
<span class="badge bg-info version-badge" data-bs-toggle="tooltip"
|
||||
title="Deployed: {{ instance.deployed_version }}{% if instance.version_checked_at %}<br>Checked: {{ instance.version_checked_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}">
|
||||
{{ instance.deployed_version[:8] if instance.deployed_version != 'unknown' else 'unknown' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary version-badge">unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
@@ -704,25 +734,37 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const headerButtons = document.querySelector('.header-buttons');
|
||||
if (headerButtons) {
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.className = 'btn btn-outline-primary';
|
||||
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh';
|
||||
refreshButton.className = 'btn btn-outline-primary me-2';
|
||||
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh All';
|
||||
refreshButton.onclick = function() {
|
||||
fetchCompanyNames();
|
||||
};
|
||||
headerButtons.appendChild(refreshButton);
|
||||
|
||||
const versionRefreshButton = document.createElement('button');
|
||||
versionRefreshButton.className = 'btn btn-outline-info';
|
||||
versionRefreshButton.innerHTML = '<i class="fas fa-code-branch"></i> Refresh Versions';
|
||||
versionRefreshButton.onclick = function() {
|
||||
refreshAllVersionInfo();
|
||||
};
|
||||
headerButtons.appendChild(versionRefreshButton);
|
||||
}
|
||||
|
||||
// Wait a short moment to ensure the table is rendered
|
||||
setTimeout(() => {
|
||||
// Check statuses on page load
|
||||
checkAllInstanceStatuses();
|
||||
setTimeout(async () => {
|
||||
// First fetch latest version information
|
||||
await fetchLatestVersion();
|
||||
|
||||
// Fetch company names for all instances
|
||||
// Then check statuses and fetch company names
|
||||
checkAllInstanceStatuses();
|
||||
fetchCompanyNames();
|
||||
}, 100);
|
||||
|
||||
// Set up periodic status checks (every 30 seconds)
|
||||
setInterval(checkAllInstanceStatuses, 30000);
|
||||
|
||||
// Set up periodic latest version checks (every 5 minutes)
|
||||
setInterval(fetchLatestVersion, 300000);
|
||||
|
||||
// Update color picker functionality
|
||||
const primaryColor = document.getElementById('primaryColor');
|
||||
@@ -766,12 +808,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
updateColorPreview();
|
||||
});
|
||||
|
||||
// Function to check status of all instances
|
||||
// Function to check all instance statuses
|
||||
async function checkAllInstanceStatuses() {
|
||||
const statusBadges = document.querySelectorAll('[data-instance-id]');
|
||||
for (const badge of statusBadges) {
|
||||
console.log('Checking all instance statuses...');
|
||||
const instances = document.querySelectorAll('[data-instance-id]');
|
||||
|
||||
for (const badge of instances) {
|
||||
const instanceId = badge.dataset.instanceId;
|
||||
await checkInstanceStatus(instanceId);
|
||||
|
||||
// Also refresh version info when checking status
|
||||
const instanceUrl = badge.closest('tr').querySelector('td:nth-child(7) a')?.href;
|
||||
const apiKey = badge.dataset.token;
|
||||
|
||||
if (instanceUrl && apiKey) {
|
||||
// Fetch version info in the background (don't await to avoid blocking status checks)
|
||||
fetchVersionInfo(instanceUrl, instanceId).catch(error => {
|
||||
console.error(`Error fetching version info for instance ${instanceId}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,19 +916,19 @@ async function fetchInstanceStats(instanceUrl, instanceId, jwtToken) {
|
||||
|
||||
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
||||
|
||||
// Update rooms count
|
||||
// Update rooms count (3rd column)
|
||||
const roomsCell = row.querySelector('td:nth-child(3)');
|
||||
if (roomsCell) {
|
||||
roomsCell.textContent = data.rooms || '0';
|
||||
}
|
||||
|
||||
// Update conversations count
|
||||
// Update conversations count (4th column)
|
||||
const conversationsCell = row.querySelector('td:nth-child(4)');
|
||||
if (conversationsCell) {
|
||||
conversationsCell.textContent = data.conversations || '0';
|
||||
}
|
||||
|
||||
// Update data usage
|
||||
// Update data usage (5th column)
|
||||
const dataCell = row.querySelector('td:nth-child(5)');
|
||||
if (dataCell) {
|
||||
const dataSize = data.total_storage || 0;
|
||||
@@ -888,6 +943,193 @@ async function fetchInstanceStats(instanceUrl, instanceId, jwtToken) {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to compare semantic versions and determine update type
|
||||
function compareSemanticVersions(currentVersion, latestVersion) {
|
||||
try {
|
||||
// Parse versions into parts (handle cases like "1.0" or "1.0.0")
|
||||
const parseVersion = (version) => {
|
||||
const parts = version.split('.').map(part => {
|
||||
const num = parseInt(part, 10);
|
||||
return isNaN(num) ? 0 : num;
|
||||
});
|
||||
// Ensure we have at least 3 parts (major.minor.patch)
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
return parts.slice(0, 3); // Only take first 3 parts
|
||||
};
|
||||
|
||||
const current = parseVersion(currentVersion);
|
||||
const latest = parseVersion(latestVersion);
|
||||
|
||||
// Compare major version
|
||||
if (current[0] < latest[0]) {
|
||||
return 'major';
|
||||
}
|
||||
|
||||
// Compare minor version
|
||||
if (current[1] < latest[1]) {
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
// Compare patch version
|
||||
if (current[2] < latest[2]) {
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
// If we get here, current version is newer or equal
|
||||
return 'up_to_date';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error comparing semantic versions:', error);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Function to fetch version information for an instance
|
||||
async function fetchVersionInfo(instanceUrl, instanceId) {
|
||||
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
||||
const versionCell = row.querySelector('td:nth-child(9)'); // Version column (adjusted after removing branch)
|
||||
|
||||
// Show loading state
|
||||
if (versionCell) {
|
||||
versionCell.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = document.querySelector(`[data-instance-id="${instanceId}"]`).dataset.token;
|
||||
if (!apiKey) {
|
||||
throw new Error('No API key available');
|
||||
}
|
||||
|
||||
console.log(`Getting JWT token for instance ${instanceId} for version info`);
|
||||
const jwtToken = await getJWTToken(instanceUrl, apiKey);
|
||||
console.log('Got JWT token for version info');
|
||||
|
||||
// Fetch version information
|
||||
console.log(`Fetching version info for instance ${instanceId} from ${instanceUrl}/api/admin/version-info`);
|
||||
const response = await fetch(`${instanceUrl}/api/admin/version-info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${jwtToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`HTTP error ${response.status}:`, errorText);
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Received version data:', data);
|
||||
|
||||
// Update version cell
|
||||
if (versionCell) {
|
||||
const appVersion = data.app_version || 'unknown';
|
||||
const gitCommit = data.git_commit || 'unknown';
|
||||
const deployedAt = data.deployed_at || 'unknown';
|
||||
|
||||
if (appVersion !== 'unknown') {
|
||||
// Get the latest version for comparison
|
||||
const latestVersionBadge = document.getElementById('latestVersionBadge');
|
||||
let latestVersion = latestVersionBadge ? latestVersionBadge.textContent.replace('Loading...', '').trim() : null;
|
||||
|
||||
// If latest version is not available yet, wait a bit and try again
|
||||
if (!latestVersion || latestVersion === '') {
|
||||
// Show loading state while waiting for latest version
|
||||
versionCell.innerHTML = `
|
||||
<span class="badge bg-secondary version-badge" data-bs-toggle="tooltip"
|
||||
title="App Version: ${appVersion}<br>Git Commit: ${gitCommit}<br>Deployed: ${deployedAt}<br>Waiting for latest version...">
|
||||
<i class="fas fa-spinner fa-spin me-1"></i>${appVersion.length > 8 ? appVersion.substring(0, 8) : appVersion}
|
||||
</span>`;
|
||||
|
||||
// Wait a bit and retry the comparison
|
||||
setTimeout(() => {
|
||||
fetchVersionInfo(instanceUrl, instanceId);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this instance is up to date
|
||||
let badgeClass = 'bg-secondary';
|
||||
let statusIcon = 'fas fa-tag';
|
||||
let tooltipText = `App Version: ${appVersion}<br>Git Commit: ${gitCommit}<br>Deployed: ${deployedAt}`;
|
||||
|
||||
if (latestVersion && appVersion === latestVersion) {
|
||||
// Exact match - green
|
||||
badgeClass = 'bg-success';
|
||||
statusIcon = 'fas fa-check-circle';
|
||||
tooltipText += '<br><strong>✅ Up to date</strong>';
|
||||
} else if (latestVersion && appVersion !== latestVersion) {
|
||||
// Compare semantic versions
|
||||
const versionComparison = compareSemanticVersions(appVersion, latestVersion);
|
||||
|
||||
switch (versionComparison) {
|
||||
case 'patch':
|
||||
// Only patch version different - yellow
|
||||
badgeClass = 'bg-warning';
|
||||
statusIcon = 'fas fa-exclamation-triangle';
|
||||
tooltipText += `<br><strong>🟡 Patch update available (Latest: ${latestVersion})</strong>`;
|
||||
break;
|
||||
case 'minor':
|
||||
// Minor version different - orange
|
||||
badgeClass = 'bg-orange';
|
||||
statusIcon = 'fas fa-exclamation-triangle';
|
||||
tooltipText += `<br><strong>🟠 Minor update available (Latest: ${latestVersion})</strong>`;
|
||||
break;
|
||||
case 'major':
|
||||
// Major version different - red
|
||||
badgeClass = 'bg-danger';
|
||||
statusIcon = 'fas fa-exclamation-triangle';
|
||||
tooltipText += `<br><strong>🔴 Major update available (Latest: ${latestVersion})</strong>`;
|
||||
break;
|
||||
default:
|
||||
// Unknown format or comparison failed - red
|
||||
badgeClass = 'bg-danger';
|
||||
statusIcon = 'fas fa-exclamation-triangle';
|
||||
tooltipText += `<br><strong>🔴 Outdated (Latest: ${latestVersion})</strong>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
versionCell.innerHTML = `
|
||||
<span class="badge ${badgeClass} version-badge" data-bs-toggle="tooltip"
|
||||
title="${tooltipText}">
|
||||
<i class="${statusIcon} me-1"></i>${appVersion.length > 8 ? appVersion.substring(0, 8) : appVersion}
|
||||
</span>`;
|
||||
} else {
|
||||
versionCell.innerHTML = '<span class="badge bg-secondary version-badge">unknown</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Update tooltips
|
||||
const versionBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
|
||||
|
||||
if (versionBadge) {
|
||||
new bootstrap.Tooltip(versionBadge);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching version info for instance ${instanceId}:`, error);
|
||||
|
||||
// Show error state
|
||||
if (versionCell) {
|
||||
versionCell.innerHTML = `
|
||||
<span class="text-warning" data-bs-toggle="tooltip" title="Error: ${error.message}">
|
||||
<i class="fas fa-exclamation-triangle"></i> Error
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// Add tooltips for error states
|
||||
const errorBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
|
||||
if (errorBadge) {
|
||||
new bootstrap.Tooltip(errorBadge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to fetch company name from instance settings
|
||||
async function fetchCompanyName(instanceUrl, instanceId) {
|
||||
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
||||
@@ -975,53 +1217,30 @@ async function fetchCompanyName(instanceUrl, instanceId) {
|
||||
|
||||
// Function to fetch company names for all instances
|
||||
async function fetchCompanyNames() {
|
||||
console.log('Starting fetchCompanyNames...');
|
||||
|
||||
const instances = document.querySelectorAll('[data-instance-id]');
|
||||
const loadingPromises = [];
|
||||
|
||||
console.log('Starting to fetch company names and stats for all instances');
|
||||
|
||||
for (const instance of instances) {
|
||||
const instanceId = instance.dataset.instanceId;
|
||||
const row = instance.closest('tr');
|
||||
for (const badge of instances) {
|
||||
const instanceId = badge.dataset.instanceId;
|
||||
const instanceUrl = badge.closest('tr').querySelector('td:nth-child(7) a')?.href;
|
||||
const apiKey = badge.dataset.token;
|
||||
|
||||
// Debug: Log all cells in the row
|
||||
console.log(`Row for instance ${instanceId}:`, {
|
||||
cells: Array.from(row.querySelectorAll('td')).map(td => ({
|
||||
text: td.textContent.trim(),
|
||||
html: td.innerHTML.trim()
|
||||
}))
|
||||
});
|
||||
|
||||
// Changed from nth-child(8) to nth-child(7) since Main URL is the 7th column
|
||||
const urlCell = row.querySelector('td:nth-child(7)');
|
||||
|
||||
if (!urlCell) {
|
||||
console.error(`Could not find URL cell for instance ${instanceId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const urlLink = urlCell.querySelector('a');
|
||||
if (!urlLink) {
|
||||
console.error(`Could not find URL link for instance ${instanceId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const instanceUrl = urlLink.getAttribute('href');
|
||||
const token = instance.dataset.token;
|
||||
|
||||
console.log(`Instance ${instanceId}:`, {
|
||||
url: instanceUrl,
|
||||
hasToken: !!token
|
||||
});
|
||||
|
||||
if (instanceUrl && token) {
|
||||
loadingPromises.push(fetchCompanyName(instanceUrl, instanceId));
|
||||
if (instanceUrl && apiKey) {
|
||||
console.log(`Fetching data for instance ${instanceId}`);
|
||||
loadingPromises.push(
|
||||
fetchCompanyName(instanceUrl, instanceId),
|
||||
fetchVersionInfo(instanceUrl, instanceId) // Add version info fetching
|
||||
);
|
||||
} else {
|
||||
const row = badge.closest('tr');
|
||||
const cells = [
|
||||
row.querySelector('td:nth-child(2)'), // Company
|
||||
row.querySelector('td:nth-child(3)'), // Rooms
|
||||
row.querySelector('td:nth-child(4)'), // Conversations
|
||||
row.querySelector('td:nth-child(5)') // Data
|
||||
row.querySelector('td:nth-child(5)'), // Data
|
||||
row.querySelector('td:nth-child(9)') // Version
|
||||
];
|
||||
|
||||
cells.forEach(cell => {
|
||||
@@ -1041,7 +1260,7 @@ async function fetchCompanyNames() {
|
||||
|
||||
try {
|
||||
await Promise.all(loadingPromises);
|
||||
console.log('Finished fetching all company names and stats');
|
||||
console.log('Finished fetching all company names, stats, and version info');
|
||||
} catch (error) {
|
||||
console.error('Error in fetchCompanyNames:', error);
|
||||
}
|
||||
@@ -1108,8 +1327,8 @@ async function editInstance(id) {
|
||||
// Get the name from the first cell
|
||||
const name = row.querySelector('td:first-child').textContent.trim();
|
||||
|
||||
// Get the main URL from the link in the URL cell (7th column)
|
||||
const urlCell = row.querySelector('td:nth-child(7)');
|
||||
// Get the main URL from the link in the URL cell (9th column after adding Version and Branch)
|
||||
const urlCell = row.querySelector('td:nth-child(9)');
|
||||
const urlLink = urlCell.querySelector('a');
|
||||
const mainUrl = urlLink ? urlLink.getAttribute('href') : urlCell.textContent.trim();
|
||||
|
||||
@@ -1884,5 +2103,134 @@ function launchInstance() {
|
||||
// Redirect to the launch progress page
|
||||
window.location.href = '/instances/launch-progress';
|
||||
}
|
||||
|
||||
// Function to refresh all version information
|
||||
async function refreshAllVersionInfo() {
|
||||
console.log('Refreshing all version information...');
|
||||
const instances = document.querySelectorAll('[data-instance-id]');
|
||||
const loadingPromises = [];
|
||||
|
||||
for (const badge of instances) {
|
||||
const instanceId = badge.dataset.instanceId;
|
||||
const instanceUrl = badge.closest('tr').querySelector('td:nth-child(7) a')?.href;
|
||||
const apiKey = badge.dataset.token;
|
||||
|
||||
if (instanceUrl && apiKey) {
|
||||
console.log(`Refreshing version info for instance ${instanceId}`);
|
||||
loadingPromises.push(fetchVersionInfo(instanceUrl, instanceId));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(loadingPromises);
|
||||
console.log('Finished refreshing all version information');
|
||||
} catch (error) {
|
||||
console.error('Error refreshing version information:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to fetch latest version information
|
||||
async function fetchLatestVersion() {
|
||||
console.log('Fetching latest version information...');
|
||||
|
||||
const versionBadge = document.getElementById('latestVersionBadge');
|
||||
const commitSpan = document.getElementById('latestCommit');
|
||||
const checkedSpan = document.getElementById('lastChecked');
|
||||
|
||||
// Show loading state
|
||||
if (versionBadge) {
|
||||
versionBadge.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Loading...';
|
||||
versionBadge.className = 'badge bg-secondary fs-6';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/latest-version', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`HTTP error ${response.status}:`, errorText);
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Received latest version data:', data);
|
||||
|
||||
if (data.success) {
|
||||
// Update version badge
|
||||
if (versionBadge) {
|
||||
const version = data.latest_version;
|
||||
if (version !== 'unknown') {
|
||||
versionBadge.innerHTML = `<i class="fas fa-tag me-1"></i>${version}`;
|
||||
versionBadge.className = 'badge bg-success fs-6';
|
||||
} else {
|
||||
versionBadge.innerHTML = '<i class="fas fa-question-circle me-1"></i>Unknown';
|
||||
versionBadge.className = 'badge bg-warning fs-6';
|
||||
}
|
||||
}
|
||||
|
||||
// Update commit information
|
||||
if (commitSpan) {
|
||||
const commit = data.latest_commit;
|
||||
if (commit !== 'unknown') {
|
||||
commitSpan.textContent = commit.substring(0, 8);
|
||||
commitSpan.title = commit;
|
||||
} else {
|
||||
commitSpan.textContent = 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Update last checked time
|
||||
if (checkedSpan) {
|
||||
const lastChecked = data.last_checked;
|
||||
if (lastChecked) {
|
||||
const date = new Date(lastChecked);
|
||||
checkedSpan.textContent = date.toLocaleString();
|
||||
} else {
|
||||
checkedSpan.textContent = 'Never';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle error response
|
||||
if (versionBadge) {
|
||||
versionBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Error';
|
||||
versionBadge.className = 'badge bg-danger fs-6';
|
||||
}
|
||||
if (commitSpan) {
|
||||
commitSpan.textContent = 'Error';
|
||||
}
|
||||
if (checkedSpan) {
|
||||
checkedSpan.textContent = 'Error';
|
||||
}
|
||||
console.error('Error in latest version response:', data.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest version:', error);
|
||||
|
||||
// Show error state
|
||||
if (versionBadge) {
|
||||
versionBadge.innerHTML = '<i class="fas fa-exclamation-triangle me-1"></i>Error';
|
||||
versionBadge.className = 'badge bg-danger fs-6';
|
||||
}
|
||||
if (commitSpan) {
|
||||
commitSpan.textContent = 'Error';
|
||||
}
|
||||
if (checkedSpan) {
|
||||
checkedSpan.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to refresh latest version (called by button)
|
||||
async function refreshLatestVersion() {
|
||||
console.log('Manual refresh of latest version requested');
|
||||
await fetchLatestVersion();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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
55
test_version_api.py
Normal 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()
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"tag": "v1.2.3",
|
||||
"commit": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
|
||||
"branch": "main",
|
||||
"deployed_at": "2024-01-15T10:30:00.000000"
|
||||
}
|
||||
Reference in New Issue
Block a user