diff --git a/opt/torero-ui/torero_ui/dashboard/services.py b/opt/torero-ui/torero_ui/dashboard/services.py index c5d0ec2..3955b64 100644 --- a/opt/torero-ui/torero_ui/dashboard/services.py +++ b/opt/torero-ui/torero_ui/dashboard/services.py @@ -12,7 +12,17 @@ from .models import ServiceExecution, ServiceInfo -logger = logging.getLogger(__name__) +# logger with fallback for initialization issues +def get_logger(): + try: + return logging.getLogger(__name__) + except: + + # fallback to basic logging if django logging not initialized + logging.basicConfig(level=logging.INFO) + return logging.getLogger(__name__) + +logger = get_logger() class ToreroCliClient: @@ -21,6 +31,15 @@ class ToreroCliClient: def __init__(self) -> None: self.torero_command = "torero" self.timeout = getattr(settings, 'TORERO_CLI_TIMEOUT', 30) + + # check if torero is available + try: + result = subprocess.run([self.torero_command, "version"], + capture_output=True, text=True, timeout=5, check=False) + if result.returncode != 0: + logger.warning(f"torero command may not be available: {result.stderr}") + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.error(f"torero command not found or not responding: {e}") def _execute_command(self, args: List[str]) -> Optional[Dict[str, Any]]: """execute torero cli command and return parsed json output.""" @@ -39,9 +58,13 @@ def _execute_command(self, args: List[str]) -> Optional[Dict[str, Any]]: return json.loads(result.stdout) except json.JSONDecodeError: logger.warning(f"failed to parse json from torero output: {result.stdout[:200]}") + logger.warning(f"command was: {' '.join(command)}") return None else: - logger.error(f"torero command failed: {' '.join(command)}\nError: {result.stderr}") + logger.error(f"torero command failed: {' '.join(command)}") + logger.error(f"return code: {result.returncode}") + logger.error(f"stdout: {result.stdout[:500] if result.stdout else 'None'}") + logger.error(f"stderr: {result.stderr[:500] if result.stderr else 'None'}") return None except subprocess.TimeoutExpired: @@ -53,24 +76,33 @@ def _execute_command(self, args: List[str]) -> Optional[Dict[str, Any]]: def get_services(self) -> List[Dict[str, Any]]: """get list of all services from torero cli.""" + data = self._execute_command(["get", "services"]) if data and isinstance(data, dict) and "services" in data: return data["services"] if isinstance(data["services"], list) else [] return [] def get_service_details(self, service_name: str) -> Optional[Dict[str, Any]]: + """get details for specific service from torero cli.""" return self._execute_command(["describe", "service", service_name]) def execute_service(self, service_name: str, service_type: str, **params: Any) -> Optional[Dict[str, Any]]: """execute a service via torero cli.""" + + # extract operation parameter for opentofu + operation = params.pop('operation', None) + # build command based on service type if service_type == "ansible-playbook": command = ["run", "service", "ansible-playbook", service_name] elif service_type == "python-script": command = ["run", "service", "python-script", service_name] elif service_type == "opentofu-plan": - command = ["run", "service", "opentofu-plan", service_name] + + # use provided operation or default to apply + op = operation if operation in ['apply', 'destroy'] else 'apply' + command = ["run", "service", "opentofu-plan", op, service_name] else: logger.error(f"unsupported service type: {service_type}") return None @@ -79,7 +111,25 @@ def execute_service(self, service_name: str, service_type: str, **params: Any) - for key, value in params.items(): command.extend([f"--{key}", str(value)]) - return self._execute_command(command) + logger.info(f"executing torero command: {' '.join(command)}") + + # quick connectivity check before executing + try: + version_check = subprocess.run([self.torero_command, "version"], + capture_output=True, text=True, timeout=5, check=False) + if version_check.returncode != 0: + logger.error(f"torero connectivity check failed before execution: {version_check.stderr}") + return None + except Exception as e: + logger.error(f"torero connectivity check failed: {e}") + return None + + result = self._execute_command(command) + + if result is None: + logger.error(f"execution failed for service {service_name} (type: {service_type})") + + return result class DataCollectionService: diff --git a/opt/torero-ui/torero_ui/dashboard/urls.py b/opt/torero-ui/torero_ui/dashboard/urls.py index 20b467f..75970ea 100644 --- a/opt/torero-ui/torero_ui/dashboard/urls.py +++ b/opt/torero-ui/torero_ui/dashboard/urls.py @@ -12,4 +12,5 @@ path("api/sync/", views.api_sync_services, name="api_sync"), path("api/execution//", views.api_execution_details, name="api_execution_details"), path("api/record-execution/", views.api_record_execution, name="api_record_execution"), + path("api/execute//", views.api_execute_service, name="api_execute_service"), ] \ No newline at end of file diff --git a/opt/torero-ui/torero_ui/dashboard/views.py b/opt/torero-ui/torero_ui/dashboard/views.py index ee7e48f..bd5019c 100644 --- a/opt/torero-ui/torero_ui/dashboard/views.py +++ b/opt/torero-ui/torero_ui/dashboard/views.py @@ -1,6 +1,7 @@ """views for torero dashboard.""" import json +import logging from typing import Any, Dict from django.conf import settings @@ -11,7 +12,19 @@ from django.views.generic import TemplateView from .models import ServiceExecution, ServiceInfo -from .services import DataCollectionService +from .services import DataCollectionService, ToreroCliClient + +# logger with fallback for initialization issues +def get_logger(): + try: + return logging.getLogger(__name__) + except: + + # fallback to basic logging if django logging not initialized + logging.basicConfig(level=logging.INFO) + return logging.getLogger(__name__) + +logger = get_logger() class DashboardView(TemplateView): @@ -109,6 +122,7 @@ def api_dashboard_data(request): @require_http_methods(["POST"]) def api_sync_services(request): """api endpoint to trigger service sync.""" + try: data_service = DataCollectionService() data_service.sync_services() @@ -120,6 +134,7 @@ def api_sync_services(request): @require_http_methods(["GET"]) def api_execution_details(request, execution_id): """api endpoint for execution details.""" + try: execution = ServiceExecution.objects.get(id=execution_id) @@ -147,6 +162,7 @@ def api_execution_details(request, execution_id): @require_http_methods(["POST"]) def api_record_execution(request): """API endpoint to record execution data from torero-api.""" + try: data = json.loads(request.body) service_name = data.get('service_name') @@ -179,4 +195,61 @@ def api_record_execution(request): }) except Exception as e: - return JsonResponse({'error': str(e)}, status=500) \ No newline at end of file + return JsonResponse({'error': str(e)}, status=500) + + +@require_http_methods(["POST"]) +def api_execute_service(request, service_name): + """execute service via torero cli.""" + + try: + # get service info + service_info = ServiceInfo.objects.get(name=service_name) + + # parse request body for additional parameters + operation = None + if request.body: + try: + data = json.loads(request.body) + operation = data.get('operation') + except json.JSONDecodeError: + pass + + # execute via cli client + cli_client = ToreroCliClient() + result = cli_client.execute_service( + service_name=service_name, + service_type=service_info.service_type, + operation=operation + ) + + if result: + return JsonResponse({ + 'status': 'started', + 'message': f'execution started for {service_name}' + }) + else: + # provide more detailed error message + error_msg = f'failed to start execution for {service_name} (type: {service_info.service_type})' + logger.error(error_msg) + logger.error(f'cli client returned None for service: {service_name}') + + # check if it's a common issue + if not hasattr(settings, 'TORERO_CLI_TIMEOUT'): + logger.warning('TORERO_CLI_TIMEOUT not configured, using default') + + return JsonResponse({ + 'status': 'error', + 'message': f'{error_msg} - check server logs for details' + }, status=500) + + except ServiceInfo.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'message': 'service not found' + }, status=404) + except Exception as e: + return JsonResponse({ + 'status': 'error', + 'message': str(e) + }, status=500) \ No newline at end of file diff --git a/opt/torero-ui/torero_ui/static/css/dashboard.css b/opt/torero-ui/torero_ui/static/css/dashboard.css index 96d3d77..f783c07 100644 --- a/opt/torero-ui/torero_ui/static/css/dashboard.css +++ b/opt/torero-ui/torero_ui/static/css/dashboard.css @@ -192,6 +192,176 @@ body { color: #5cfcfe; } +/* service action buttons */ +.service-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.execute-btn, .history-btn { + flex: 1; + padding: 8px 12px; + border: 1px solid #5cfcfe; + background: transparent; + color: #5cfcfe; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-family: 'Consolas', monospace; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 1px; + display: flex; + align-items: center; + justify-content: center; +} + +.execute-btn:hover, .history-btn:hover { + background: #5cfcfe; + color: #000000; +} + +.execute-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background: transparent; + color: #5cfcfe; +} + +/* execute dropdown styling */ +.execute-dropdown { + position: relative; + flex: 1; +} + +.dropdown-toggle { + width: 100%; + position: relative; +} + +.dropdown-arrow { + font-size: 10px; + margin-left: 4px; + transition: transform 0.2s ease; +} + +.dropdown-toggle.open .dropdown-arrow { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #111; + border: 1px solid #333; + border-top: none; + z-index: 1000; + display: none; +} + +.dropdown-menu.show { + display: block; +} + +.dropdown-item { + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: #5cfcfe; + font-family: 'Consolas', monospace; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + transition: background-color 0.2s ease; + text-align: left; +} + +.dropdown-item:hover { + background: #222; +} + +.dropdown-item.destroy-option { + color: #ff4444; +} + +.dropdown-item.destroy-option:hover { + background: #ff4444; + color: #000000; +} + +.btn-spinner { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.spinner { + animation: spin 1s linear infinite; + width: 10px; + height: 10px; + border: 1px solid #5cfcfe; + border-top: 1px solid transparent; + border-radius: 50%; +} + +/* execution feedback states */ +.status-card.execution-success { + border-color: #ccff00; + box-shadow: 0 0 10px rgba(204, 255, 0, 0.3); +} + +.status-card.execution-error { + border-color: #ff4444; + box-shadow: 0 0 10px rgba(255, 68, 68, 0.3); +} + +/* toast notifications */ +.toast { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 4px; + color: #ffffff; + font-family: 'Consolas', monospace; + font-size: 14px; + z-index: 1000; + transform: translateX(100%); + transition: transform 0.3s ease; +} + +.toast.show { + transform: translateX(0); +} + +.toast-success { + background-color: #ccff00; + color: #000000; + border: 1px solid #ccff00; +} + +.toast-error { + background-color: #ff4444; + color: #ffffff; + border: 1px solid #ff4444; +} + +.toast-info { + background-color: #5cfcfe; + color: #000000; + border: 1px solid #5cfcfe; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + /* tabs */ .tabs-container { background-color: #111; diff --git a/opt/torero-ui/torero_ui/static/js/dashboard.js b/opt/torero-ui/torero_ui/static/js/dashboard.js index f966cfa..fa18eed 100644 --- a/opt/torero-ui/torero_ui/static/js/dashboard.js +++ b/opt/torero-ui/torero_ui/static/js/dashboard.js @@ -179,6 +179,37 @@ function updateServiceStatus(services) { Success Rate: ${service.success_rate.toFixed(1)}% +
+ ${service.service_type === 'opentofu-plan' ? ` +
+ + +
+ ` : ` + + `} + +
`; container.appendChild(card); @@ -472,40 +503,13 @@ function formatOpenTofuOutput(executionData) { // parse the data if it's a string const data = typeof executionData === 'string' ? JSON.parse(executionData) : executionData; - // generate unique ids for tabs within this execution - const tabPrefix = 'tofu-' + Date.now(); - return ` -
-
- - - - - -
- -
+
+

Console Output

+
${formatConsoleOutput(data)}
- -
- ${formatStateFile(data.state_file)} -
- -
- ${formatTofuOutputs(data.state_file?.outputs)} -
- -
- ${formatTimingInfo(data)} -
- -
-
${escapeHtml(JSON.stringify(data, null, 2))}
-
-
- `; +
`; } // format console output with ansi to html conversion using dracula theme @@ -825,39 +829,14 @@ function switchOpenTofuTab(tabId, button) { // python script output formatting functions function formatPythonOutput(executionData) { const data = typeof executionData === 'string' ? JSON.parse(executionData) : executionData; - const tabPrefix = 'python-' + Date.now(); return ` -
-
- - - - - -
- -
+
+

Console Output

+
${formatPythonConsoleOutput(data)}
- -
- ${formatPythonStackTrace(data)} -
- -
- ${formatPythonPerformance(data)} -
- -
- ${formatPythonEnvironment(data)} -
- -
-
${escapeHtml(JSON.stringify(data, null, 2))}
-
-
- `; +
`; } // format python console output with log level detection @@ -1158,44 +1137,43 @@ function switchPythonTab(tabId, button) { // ansible playbook output formatting functions function formatAnsibleOutput(executionData) { const data = typeof executionData === 'string' ? JSON.parse(executionData) : executionData; - const tabPrefix = 'ansible-' + Date.now(); return ` -
-
- - - - - - -
- -
- ${formatAnsibleSummary(data)} -
- -
- ${formatAnsibleTasks(data)} +
+

Console Output

+
+ ${formatAnsibleConsoleOutput(data)}
- -
- ${formatAnsibleHosts(data)} -
- -
- ${formatAnsibleVariables(data)} -
- -
- ${formatAnsibleConsole(data)} +
`; +} + +// format ansible console output +function formatAnsibleConsoleOutput(data) { + if (!data.stdout && !data.stderr) { + return '
No console output available
'; + } + + let html = ''; + + if (data.stdout) { + html += ` +
+

Standard Output

+
${escapeHtml(data.stdout)}
- -
-
${escapeHtml(JSON.stringify(data, null, 2))}
+ `; + } + + if (data.stderr) { + html += ` +
+

Standard Error

+
${escapeHtml(data.stderr)}
-
- `; + `; + } + + return html; } // format ansible play summary @@ -1655,4 +1633,205 @@ window.onclick = function(event) { if (event.target === modal) { closeExecutionModal(); } -}; \ No newline at end of file +}; + +// toggle execute dropdown for opentofu services +function toggleExecuteDropdown(button) { + const dropdown = button.nextElementSibling; + const isOpen = dropdown.classList.contains('show'); + + // close all other dropdowns first + document.querySelectorAll('.dropdown-menu.show').forEach(menu => { + menu.classList.remove('show'); + menu.previousElementSibling.classList.remove('open'); + }); + + // toggle this dropdown + if (!isOpen) { + dropdown.classList.add('show'); + button.classList.add('open'); + } +} + +// close dropdowns when clicking outside +document.addEventListener('click', function(event) { + if (!event.target.closest('.execute-dropdown')) { + document.querySelectorAll('.dropdown-menu.show').forEach(menu => { + menu.classList.remove('show'); + menu.previousElementSibling.classList.remove('open'); + }); + } +}); + +// execute opentofu service with specific operation +async function executeOpenTofu(serviceName, operation, button) { + // close the dropdown + const dropdown = button.closest('.dropdown-menu'); + const toggleButton = dropdown.previousElementSibling; + dropdown.classList.remove('show'); + toggleButton.classList.remove('open'); + + // update toggle button state to executing + const btnText = toggleButton.querySelector('.btn-text'); + const btnSpinner = toggleButton.querySelector('.btn-spinner'); + const btnArrow = toggleButton.querySelector('.dropdown-arrow'); + + btnText.style.display = 'none'; + btnArrow.style.display = 'none'; + btnSpinner.style.display = 'inline-flex'; + toggleButton.disabled = true; + + try { + const response = await fetch(`/api/execute/${serviceName}/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCsrfToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + operation: operation + }), + }); + + const result = await response.json(); + + if (result.status === 'started') { + // show success feedback on the card + showExecutionFeedback(toggleButton, 'success', `${operation} started`); + + // refresh dashboard after short delay to show new execution + setTimeout(() => { + refreshDashboard(); + }, 2000); + + } else { + showExecutionFeedback(toggleButton, 'error', result.message); + } + + } catch (error) { + console.error('execution error:', error); + showExecutionFeedback(toggleButton, 'error', 'network error'); + } +} + +// execute service functionality +async function executeService(serviceName, button) { + // update button state to executing + const btnText = button.querySelector('.btn-text'); + const btnSpinner = button.querySelector('.btn-spinner'); + + btnText.style.display = 'none'; + btnSpinner.style.display = 'inline-flex'; + button.disabled = true; + + try { + const response = await fetch(`/api/execute/${serviceName}/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCsrfToken(), + 'Content-Type': 'application/json', + }, + }); + + const result = await response.json(); + + if (result.status === 'started') { + // show success feedback + showExecutionFeedback(button, 'success', 'execution started'); + + // refresh dashboard after short delay to show new execution + setTimeout(() => { + refreshDashboard(); + }, 2000); + + } else { + showExecutionFeedback(button, 'error', result.message); + } + + } catch (error) { + console.error('execution error:', error); + showExecutionFeedback(button, 'error', 'network error'); + } +} + +// show execution feedback on service card +function showExecutionFeedback(button, type, message) { + const card = button.closest('.status-card'); + + // add visual feedback class + card.classList.add(`execution-${type}`); + + // reset button + resetExecuteButton(button); + + // remove feedback after 2 seconds + setTimeout(() => { + card.classList.remove(`execution-${type}`); + }, 2000); + + // show toast notification + showToast(message, type); +} + +// reset execute button to default state +function resetExecuteButton(button) { + const btnText = button.querySelector('.btn-text'); + const btnSpinner = button.querySelector('.btn-spinner'); + const btnArrow = button.querySelector('.dropdown-arrow'); + + btnText.style.display = 'inline'; + btnSpinner.style.display = 'none'; + if (btnArrow) { + btnArrow.style.display = 'inline'; + } + button.disabled = false; +} + +// show service history (placeholder for future implementation) +function showServiceHistory(serviceName) { + showToast(`history for ${serviceName} (coming soon)`, 'info'); +} + +// show toast notification +function showToast(message, type = 'info') { + // create toast element + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + + // add to page + document.body.appendChild(toast); + + // show toast + setTimeout(() => { + toast.classList.add('show'); + }, 100); + + // hide and remove toast + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => { + document.body.removeChild(toast); + }, 300); + }, 3000); +} + +// get csrf token for requests +function getCsrfToken() { + // get from django's hidden csrf input + const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]'); + if (csrfInput) { + return csrfInput.value; + } + + // fallback to cookie method + const cookies = document.cookie.split(';'); + for (let cookie of cookies) { + const [name, value] = cookie.trim().split('='); + if (name === 'csrftoken') { + return value; + } + } + + return ''; +} \ No newline at end of file diff --git a/opt/torero-ui/torero_ui/templates/dashboard/base.html b/opt/torero-ui/torero_ui/templates/dashboard/base.html index df0efc1..d01ef43 100644 --- a/opt/torero-ui/torero_ui/templates/dashboard/base.html +++ b/opt/torero-ui/torero_ui/templates/dashboard/base.html @@ -3,6 +3,7 @@ + {% csrf_token %} {% block title %}torero Opsboard{% endblock %} {% load static %} diff --git a/opt/torero-ui/torero_ui/templates/dashboard/index.html b/opt/torero-ui/torero_ui/templates/dashboard/index.html index ffa62bf..952c676 100644 --- a/opt/torero-ui/torero_ui/templates/dashboard/index.html +++ b/opt/torero-ui/torero_ui/templates/dashboard/index.html @@ -59,6 +59,37 @@ Success Rate: {{ service.success_rate|floatformat:1 }}%
+
+ {% if service.service_type == "opentofu-plan" %} +
+ + +
+ {% else %} + + {% endif %} + +
{% empty %}