From 4b9eb2f3592b0ef17b5dd7227d17090cd760ce3b Mon Sep 17 00:00:00 2001 From: Simon Diesenreiter Date: Fri, 10 Apr 2026 20:55:43 +0200 Subject: [PATCH] chore: add more health info for n8n, refs NOISSUE --- ai_software_factory/agents/n8n_setup.py | 180 +++++++++++++++++++++--- ai_software_factory/dashboard_ui.py | 142 ++++++++++++++++++- ai_software_factory/frontend.py | 8 +- ai_software_factory/main.py | 12 +- 4 files changed, 315 insertions(+), 27 deletions(-) diff --git a/ai_software_factory/agents/n8n_setup.py b/ai_software_factory/agents/n8n_setup.py index 0c923d5..7c89698 100644 --- a/ai_software_factory/agents/n8n_setup.py +++ b/ai_software_factory/agents/n8n_setup.py @@ -1,6 +1,8 @@ """n8n setup agent for automatic webhook configuration.""" import json +from urllib import error as urllib_error +from urllib import request as urllib_request from typing import Optional try: @@ -46,6 +48,113 @@ class N8NSetupAgent: headers["X-N8N-API-KEY"] = self.webhook_token return headers + def _extract_message(self, payload: object) -> str: + """Extract a useful message from an n8n response payload.""" + if isinstance(payload, dict): + for key in ("message", "error", "reason", "hint", "text"): + value = payload.get(key) + if value: + return str(value) + if payload: + return json.dumps(payload) + if payload is None: + return "No response body" + return str(payload) + + def _normalize_success(self, method: str, url: str, status_code: int, payload: object) -> dict: + """Normalize a successful n8n API response.""" + if isinstance(payload, dict): + response = dict(payload) + response.setdefault("status_code", status_code) + response.setdefault("url", url) + response.setdefault("method", method) + return response + return {"data": payload, "status_code": status_code, "url": url, "method": method} + + def _normalize_error(self, method: str, url: str, status_code: int | None, payload: object) -> dict: + """Normalize an error response with enough detail for diagnostics.""" + message = self._extract_message(payload) + prefix = f"{method} {url}" + if status_code is not None: + return { + "error": f"{prefix} returned {status_code}: {message}", + "message": message, + "status_code": status_code, + "url": url, + "method": method, + "payload": payload, + } + return { + "error": f"{prefix} failed: {message}", + "message": message, + "status_code": None, + "url": url, + "method": method, + "payload": payload, + } + + def _health_check_row(self, name: str, result: dict) -> dict: + """Convert a raw request result into a UI/API-friendly health check row.""" + return { + "name": name, + "ok": not bool(result.get("error")), + "url": result.get("url"), + "method": result.get("method", "GET"), + "status_code": result.get("status_code"), + "message": result.get("message") or ("ok" if not result.get("error") else result.get("error")), + } + + def _health_suggestion(self, checks: list[dict]) -> str | None: + """Return a suggestion based on failed n8n health checks.""" + status_codes = {check.get("status_code") for check in checks if check.get("status_code") is not None} + if status_codes and status_codes.issubset({404}): + return "Verify N8N_API_URL points to the base n8n URL, for example http://host:5678, not /api/v1 or a webhook URL." + if status_codes & {401, 403}: + return "Check the configured n8n API key or authentication method." + return "Verify the n8n URL, API key, and that the n8n API is reachable from this container." + + def _build_health_result(self, healthz_result: dict, workflows_result: dict) -> dict: + """Build a consolidated health result from the performed checks.""" + checks = [ + self._health_check_row("healthz", healthz_result), + self._health_check_row("workflows", workflows_result), + ] + + if not healthz_result.get("error"): + return { + "status": "ok", + "message": "n8n is reachable via /healthz.", + "api_url": self.api_url, + "auth_configured": bool(self.webhook_token), + "checked_via": "healthz", + "checks": checks, + } + + if not workflows_result.get("error"): + workflows = workflows_result.get("data") + workflow_count = len(workflows) if isinstance(workflows, list) else None + return { + "status": "ok", + "message": "n8n is reachable via the workflows API, but /healthz is unavailable.", + "api_url": self.api_url, + "auth_configured": bool(self.webhook_token), + "checked_via": "workflows", + "workflow_count": workflow_count, + "checks": checks, + } + + suggestion = self._health_suggestion(checks) + return { + "status": "error", + "error": "n8n health checks failed", + "message": "n8n health checks failed.", + "api_url": self.api_url, + "auth_configured": bool(self.webhook_token), + "checked_via": "none", + "checks": checks, + "suggestion": suggestion, + } + async def _request(self, method: str, path: str, **kwargs) -> dict: """Send a request to n8n and normalize the response.""" import aiohttp @@ -62,15 +171,42 @@ class N8NSetupAgent: payload = {"text": await resp.text()} if 200 <= resp.status < 300: - if isinstance(payload, dict): - payload.setdefault("status_code", resp.status) - return payload - return {"data": payload, "status_code": resp.status} + return self._normalize_success(method, url, resp.status, payload) - message = payload.get("message") if isinstance(payload, dict) else str(payload) - return {"error": f"Status {resp.status}: {message}", "status_code": resp.status, "payload": payload} + return self._normalize_error(method, url, resp.status, payload) except Exception as e: - return {"error": str(e)} + return self._normalize_error(method, url, None, {"message": str(e)}) + + def _request_sync(self, method: str, path: str, **kwargs) -> dict: + """Send a synchronous request to n8n for dashboard health snapshots.""" + headers = kwargs.pop("headers", None) or self.get_auth_headers() + payload = kwargs.pop("json", None) + timeout = kwargs.pop("timeout", 5) + url = self._api_path(path) + data = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + req = urllib_request.Request(url, data=data, headers=headers, method=method) + try: + with urllib_request.urlopen(req, timeout=timeout) as resp: + raw_body = resp.read().decode("utf-8") + content_type = resp.headers.get("Content-Type", "") + if "application/json" in content_type and raw_body: + parsed = json.loads(raw_body) + elif raw_body: + parsed = {"text": raw_body} + else: + parsed = {} + return self._normalize_success(method, url, resp.status, parsed) + except urllib_error.HTTPError as exc: + raw_body = exc.read().decode("utf-8") if exc.fp else "" + try: + parsed = json.loads(raw_body) if raw_body else {} + except json.JSONDecodeError: + parsed = {"text": raw_body} if raw_body else {} + return self._normalize_error(method, url, exc.code, parsed) + except Exception as exc: + return self._normalize_error(method, url, None, {"message": str(exc)}) async def get_workflow(self, workflow_name: str) -> Optional[dict]: """Get a workflow by name.""" @@ -304,12 +440,14 @@ class N8NSetupAgent: async def health_check(self) -> dict: """Check n8n API health.""" result = await self._request("GET", f"{self.api_url}/healthz") - if result.get("error"): - fallback = await self._request("GET", "workflows") - if fallback.get("error"): - return fallback - return {"status": "ok", "checked_via": "workflows"} - return {"status": "ok", "checked_via": "healthz"} + fallback = await self._request("GET", "workflows") + return self._build_health_result(result, fallback) + + def health_check_sync(self) -> dict: + """Synchronously check n8n API health for UI rendering.""" + result = self._request_sync("GET", f"{self.api_url}/healthz") + fallback = self._request_sync("GET", "workflows") + return self._build_health_result(result, fallback) async def setup( self, @@ -324,7 +462,13 @@ class N8NSetupAgent: # First, verify n8n is accessible health = await self.health_check() if health.get("error"): - return {"status": "error", "message": health.get("error")} + return { + "status": "error", + "message": health.get("message") or health.get("error"), + "health": health, + "checks": health.get("checks", []), + "suggestion": health.get("suggestion"), + } effective_backend_url = backend_url or f"{settings.backend_public_url}/generate" effective_bot_token = telegram_bot_token or settings.telegram_bot_token @@ -334,7 +478,7 @@ class N8NSetupAgent: if trigger_mode: credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name) if credential.get("error"): - return {"status": "error", "message": credential["error"]} + return {"status": "error", "message": credential["error"], "details": credential} workflow = self.build_telegram_trigger_workflow( backend_url=effective_backend_url, credential_name=effective_credential_name, @@ -347,7 +491,7 @@ class N8NSetupAgent: existing = await self.get_workflow(workflow["name"]) if isinstance(existing, dict) and existing.get("error"): - return {"status": "error", "message": existing["error"]} + return {"status": "error", "message": existing["error"], "details": existing} workflow_id = None if existing and existing.get("id"): @@ -361,12 +505,12 @@ class N8NSetupAgent: workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None if isinstance(result, dict) and result.get("error"): - return {"status": "error", "message": result["error"]} + return {"status": "error", "message": result["error"], "details": result} workflow_id = workflow_id or str(result.get("id", "")) enable_result = await self.enable_workflow(workflow_id) if enable_result.get("error"): - return {"status": "error", "message": enable_result["error"], "workflow": result} + return {"status": "error", "message": enable_result["error"], "workflow": result, "details": enable_result} return { "status": "success", diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index 930228d..ff22592 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import closing +from html import escape from nicegui import ui @@ -10,12 +11,12 @@ try: from .agents.database_manager import DatabaseManager from .agents.n8n_setup import N8NSetupAgent from .config import settings - from .database import get_db_sync, init_db + from .database import get_database_runtime_summary, get_db_sync, init_db except ImportError: from agents.database_manager import DatabaseManager from agents.n8n_setup import N8NSetupAgent from config import settings - from database import get_db_sync, init_db + from database import get_database_runtime_summary, get_db_sync, init_db def _resolve_n8n_api_url() -> str: @@ -65,8 +66,32 @@ def _load_dashboard_snapshot() -> dict: return {'error': f'Database error: {exc}'} -def create_dashboard(): - """Create the main NiceGUI dashboard.""" +def _load_n8n_health_snapshot() -> dict: + """Load an n8n health snapshot for UI rendering.""" + api_url = _resolve_n8n_api_url() + if not api_url: + return { + 'status': 'error', + 'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.', + 'api_url': 'Not configured', + 'auth_configured': bool(settings.n8n_api_key), + 'checks': [], + 'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.', + } + try: + return N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key).health_check_sync() + except Exception as exc: + return { + 'status': 'error', + 'message': f'Unable to run n8n health checks: {exc}', + 'api_url': api_url, + 'auth_configured': bool(settings.n8n_api_key), + 'checks': [], + } + + +def _add_dashboard_styles() -> None: + """Register shared dashboard styles.""" ui.add_head_html( """