diff --git a/routes/__pycache__/main.cpython-313.pyc b/routes/__pycache__/main.cpython-313.pyc index fd6bfcc..4a9675d 100644 Binary files a/routes/__pycache__/main.cpython-313.pyc and b/routes/__pycache__/main.cpython-313.pyc differ diff --git a/routes/main.py b/routes/main.py index 0e79760..1148f51 100644 --- a/routes/main.py +++ b/routes/main.py @@ -19,6 +19,7 @@ import smtplib import requests from functools import wraps import socket +from urllib.parse import urlparse # Set up logging to show in console logging.basicConfig( @@ -491,13 +492,197 @@ def init_routes(main_bp): return jsonify({'error': 'Unauthorized'}), 403 instance = Instance.query.get_or_404(instance_id) + + # Get Portainer settings + portainer_settings = KeyValueSettings.get_value('portainer_settings') + if not portainer_settings: + current_app.logger.warning(f"Portainer settings not configured, proceeding with database deletion only for instance {instance.name}") + # Continue with database deletion even if Portainer is not configured + try: + db.session.delete(instance) + db.session.commit() + current_app.logger.info(f"Successfully deleted instance from database: {instance.name}") + return jsonify({'message': 'Instance deleted from database (Portainer not configured)'}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error deleting instance {instance.name} from database: {str(e)}") + return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500 + try: + # First, delete the Portainer stack and its volumes if stack information exists + if instance.portainer_stack_id and instance.portainer_stack_name: + current_app.logger.info(f"Deleting Portainer stack: {instance.portainer_stack_name} (ID: {instance.portainer_stack_id})") + + # Get Portainer endpoint ID (assuming it's the first endpoint) + try: + endpoint_response = requests.get( + f"{portainer_settings['url'].rstrip('/')}/api/endpoints", + headers={ + 'X-API-Key': portainer_settings['api_key'], + 'Accept': 'application/json' + }, + timeout=30 + ) + + if not endpoint_response.ok: + current_app.logger.error(f"Failed to get Portainer endpoints: {endpoint_response.text}") + return jsonify({'error': 'Failed to get Portainer endpoints'}), 500 + + endpoints = endpoint_response.json() + if not endpoints: + current_app.logger.error("No Portainer endpoints found") + return jsonify({'error': 'No Portainer endpoints found'}), 400 + + endpoint_id = endpoints[0]['Id'] + + # Delete the stack (this will also remove associated volumes) + delete_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{instance.portainer_stack_id}" + delete_response = requests.delete( + delete_url, + headers={ + 'X-API-Key': portainer_settings['api_key'], + 'Accept': 'application/json' + }, + params={'endpointId': endpoint_id}, + timeout=60 # Give more time for stack deletion + ) + + if delete_response.ok: + current_app.logger.info(f"Successfully deleted Portainer stack: {instance.portainer_stack_name}") + else: + current_app.logger.warning(f"Failed to delete Portainer stack: {delete_response.status_code} - {delete_response.text}") + # Continue with database deletion even if Portainer deletion fails + + # Also try to delete any orphaned volumes associated with this stack + try: + volumes_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/volumes" + volumes_response = requests.get( + volumes_url, + headers={ + 'X-API-Key': portainer_settings['api_key'], + 'Accept': 'application/json' + }, + timeout=30 + ) + + if volumes_response.ok: + volumes = volumes_response.json().get('Volumes', []) + stack_volumes = [vol for vol in volumes if vol.get('Labels', {}).get('com.docker.stack.namespace') == instance.portainer_stack_name] + + for volume in stack_volumes: + volume_name = volume.get('Name') + if volume_name: + delete_volume_url = f"{volumes_url}/{volume_name}" + volume_delete_response = requests.delete( + delete_volume_url, + headers={ + 'X-API-Key': portainer_settings['api_key'], + 'Accept': 'application/json' + }, + timeout=30 + ) + + if volume_delete_response.ok: + current_app.logger.info(f"Successfully deleted volume: {volume_name}") + else: + current_app.logger.warning(f"Failed to delete volume {volume_name}: {volume_delete_response.status_code}") + else: + current_app.logger.warning(f"Failed to get volumes list: {volumes_response.status_code}") + except Exception as volume_error: + current_app.logger.warning(f"Error cleaning up volumes: {str(volume_error)}") + + except requests.exceptions.RequestException as req_error: + current_app.logger.error(f"Network error during Portainer operations: {str(req_error)}") + # Continue with database deletion even if Portainer operations fail + else: + current_app.logger.info(f"No Portainer stack information found for instance {instance.name}, proceeding with database deletion only") + + # Clean up NGINX proxy host if NGINX settings are configured + nginx_settings = KeyValueSettings.get_value('nginx_settings') + if nginx_settings and instance.main_url: + current_app.logger.info(f"Cleaning up NGINX proxy host for instance {instance.name}") + try: + # Extract domain from main_url + parsed_url = urlparse(instance.main_url) + domain = parsed_url.netloc + + if domain: + # Get NGINX JWT token + token_response = requests.post( + f"{nginx_settings['url'].rstrip('/')}/api/tokens", + json={ + 'identity': nginx_settings['username'], + 'secret': nginx_settings['password'] + }, + headers={'Content-Type': 'application/json'}, + timeout=30 + ) + + if token_response.ok: + token_data = token_response.json() + token = token_data.get('token') + + if token: + # Get all proxy hosts to find the one matching this domain + proxy_hosts_response = requests.get( + f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts", + headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json' + }, + timeout=30 + ) + + if proxy_hosts_response.ok: + proxy_hosts = proxy_hosts_response.json() + + # Find proxy host with matching domain + matching_proxy = None + for proxy_host in proxy_hosts: + if proxy_host.get('domain_names') and domain in proxy_host['domain_names']: + matching_proxy = proxy_host + break + + if matching_proxy: + # Delete the proxy host + delete_response = requests.delete( + f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts/{matching_proxy['id']}", + headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json' + }, + timeout=30 + ) + + if delete_response.ok: + current_app.logger.info(f"Successfully deleted NGINX proxy host for domain: {domain}") + else: + current_app.logger.warning(f"Failed to delete NGINX proxy host: {delete_response.status_code} - {delete_response.text}") + else: + current_app.logger.info(f"No NGINX proxy host found for domain: {domain}") + else: + current_app.logger.warning(f"Failed to get NGINX proxy hosts: {proxy_hosts_response.status_code}") + else: + current_app.logger.warning("No NGINX token received") + else: + current_app.logger.warning(f"Failed to authenticate with NGINX: {token_response.status_code}") + except Exception as nginx_error: + current_app.logger.warning(f"Error cleaning up NGINX proxy host: {str(nginx_error)}") + # Continue with database deletion even if NGINX cleanup fails + else: + current_app.logger.info(f"No NGINX settings configured or no main_url for instance {instance.name}, skipping NGINX cleanup") + + # Now delete the instance from the database db.session.delete(instance) db.session.commit() - return jsonify({'message': 'Instance deleted successfully'}) + + current_app.logger.info(f"Successfully deleted instance: {instance.name}") + return jsonify({'message': 'Instance and all associated resources (Portainer stack, volumes, NGINX proxy host) deleted successfully'}) + except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 400 + current_app.logger.error(f"Error deleting instance {instance.name}: {str(e)}") + return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500 @main_bp.route('/instances//status') @login_required diff --git a/static/css/instances.css b/static/css/instances.css index 31e1f96..d865cae 100644 --- a/static/css/instances.css +++ b/static/css/instances.css @@ -191,4 +191,62 @@ .modal-footer button { margin-left: 0.5rem; +} + +/* Infrastructure Tools Styles */ +.infrastructure-tools .btn { + transition: all 0.3s ease; + border-radius: 12px; + min-height: 100px; + text-decoration: none; +} + +.infrastructure-tools .btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + text-decoration: none; +} + +.infrastructure-tools .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.infrastructure-tools .btn:disabled:hover { + transform: none; + box-shadow: none; +} + +.infrastructure-tools .btn-outline-primary:hover { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: white; +} + +.infrastructure-tools .btn-outline-success:hover { + background-color: #198754; + border-color: #198754; + color: white; +} + +.infrastructure-tools .btn-outline-info:hover { + background-color: #0dcaf0; + border-color: #0dcaf0; + color: white; +} + +.infrastructure-tools .btn-outline-warning:hover { + background-color: #ffc107; + border-color: #ffc107; + color: #000; +} + +.infrastructure-tools .btn i { + transition: transform 0.3s ease; +} + +.infrastructure-tools .btn:hover i { + transform: scale(1.1); } \ No newline at end of file diff --git a/static/js/instances.js b/static/js/instances.js index 88ea743..880a8e8 100644 --- a/static/js/instances.js +++ b/static/js/instances.js @@ -1,4 +1,3 @@ - // Modal instances let addInstanceModal; let editInstanceModal; @@ -1025,8 +1024,8 @@ async function verifyConnections() { 'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value }, body: JSON.stringify({ - url: '{{ portainer_settings.url if portainer_settings else "" }}', - api_key: '{{ portainer_settings.api_key if portainer_settings else "" }}' + url: window.portainerSettings?.url || '', + api_key: window.portainerSettings?.api_key || '' }) }); @@ -1060,9 +1059,9 @@ async function verifyConnections() { 'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value }, body: JSON.stringify({ - url: '{{ nginx_settings.url if nginx_settings else "" }}', - username: '{{ nginx_settings.username if nginx_settings else "" }}', - password: '{{ nginx_settings.password if nginx_settings else "" }}' + url: window.nginxSettings?.url || '', + username: window.nginxSettings?.username || '', + password: window.nginxSettings?.password || '' }) }); @@ -1094,8 +1093,8 @@ async function loadRepositories() { const branchSelect = document.getElementById('giteaBranchSelect'); try { - const gitSettings = JSON.parse('{{ git_settings|tojson|safe }}'); - if (!gitSettings || !gitSettings.url || !gitSettings.token) { + const gitSettings = window.gitSettings || {}; + if (!gitSettings.url || !gitSettings.token) { throw new Error('No Git settings found. Please configure Git in the settings page.'); } @@ -1144,8 +1143,8 @@ async function loadBranches(repoId) { const branchSelect = document.getElementById('giteaBranchSelect'); try { - const gitSettings = JSON.parse('{{ git_settings|tojson|safe }}'); - if (!gitSettings || !gitSettings.url || !gitSettings.token) { + const gitSettings = window.gitSettings || {}; + if (!gitSettings.url || !gitSettings.token) { throw new Error('No Git settings found. Please configure Git in the settings page.'); } @@ -1727,18 +1726,52 @@ function showDeleteInstanceModal(instanceId) { async function confirmDeleteInstance() { if (!deleteInstanceId) return; + const csrfToken = document.querySelector('input[name="csrf_token"]').value; + const confirmDeleteBtn = document.getElementById('confirmDeleteBtn'); + try { + // Show loading state + const originalText = confirmDeleteBtn.innerHTML; + confirmDeleteBtn.disabled = true; + confirmDeleteBtn.innerHTML = 'Deleting...'; + const response = await fetch(`/instances/${deleteInstanceId}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } }); - if (!response.ok) throw new Error('Failed to delete instance'); - deleteInstanceModal.hide(); - location.reload(); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete instance'); + } + + // Show success message + confirmDeleteBtn.innerHTML = 'Deleted Successfully'; + confirmDeleteBtn.className = 'btn btn-success'; + + // Hide modal after a short delay + setTimeout(() => { + deleteInstanceModal.hide(); + location.reload(); + }, 1500); + } catch (error) { + // Show error state + confirmDeleteBtn.innerHTML = 'Error'; + confirmDeleteBtn.className = 'btn btn-danger'; + + // Show error message alert('Error deleting instance: ' + error.message); + + // Reset button after a delay + setTimeout(() => { + confirmDeleteBtn.disabled = false; + confirmDeleteBtn.innerHTML = 'Delete Instance'; + confirmDeleteBtn.className = 'btn btn-danger'; + }, 3000); } } \ No newline at end of file diff --git a/templates/main/instances.html b/templates/main/instances.html index c14fdf1..ec9feea 100644 --- a/templates/main/instances.html +++ b/templates/main/instances.html @@ -68,6 +68,76 @@ + +
+
+
+
+
+
+ + Infrastructure Tools +
+ + {% if not portainer_settings or not portainer_settings.url or not nginx_settings or not nginx_settings.url or not git_settings or not git_settings.url %} +
+
+ + Note: Some infrastructure tools are not configured. + Configure them in Settings to enable quick access. +
+
+ {% endif %} +
+
+
+
+
+
@@ -624,6 +694,9 @@
What will be deleted:
  • Instance configuration
  • +
  • Portainer Docker stack and all containers
  • +
  • All associated Docker volumes and data
  • +
  • NGINX proxy host configuration
  • All rooms and conversations
  • User data and files
  • Database records
  • @@ -649,5 +722,29 @@ {% endblock %} {% block extra_js %} + {% endblock %} \ No newline at end of file