7 Commits
0.6.0 ... 0.6.3

Author SHA1 Message Date
321bf74aef release: version 0.6.3 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 15s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:24:44 +02:00
55ee75106c fix: n8n workflow generation, refs NOISSUE 2026-04-10 21:24:39 +02:00
b2829caa02 release: version 0.6.2 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 29s
Upload Python Package / deploy (push) Successful in 1m11s
2026-04-10 21:14:12 +02:00
d4b280cf75 fix: fix Quasar layout issues, refs NOISSUE 2026-04-10 21:14:09 +02:00
806db8537b release: version 0.6.1 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 22s
Upload Python Package / deploy (push) Successful in 37s
2026-04-10 21:00:30 +02:00
360ed5c6f3 fix: fix commit for version push, refs NOISSUE 2026-04-10 21:00:26 +02:00
4b9eb2f359 chore: add more health info for n8n, refs NOISSUE 2026-04-10 20:55:43 +02:00
7 changed files with 380 additions and 40 deletions

View File

@@ -4,6 +4,38 @@ Changelog
(unreleased)
------------
Fix
~~~
- N8n workflow generation, refs NOISSUE. [Simon Diesenreiter]
0.6.2 (2026-04-10)
------------------
Fix
~~~
- Fix Quasar layout issues, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.6.1 (2026-04-10)
------------------
Fix
~~~
- Fix commit for version push, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
- Chore: add more health info for n8n, refs NOISSUE. [Simon
Diesenreiter]
0.6.0 (2026-04-10)
------------------
- Feat(api): expose database target in health refs NOISSUE. [Simon
Diesenreiter]
- Fix(db): prefer postgres config in production refs NOISSUE. [Simon

View File

@@ -1 +1 @@
0.6.0
0.6.3

View File

@@ -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."""
@@ -84,11 +220,31 @@ class N8NSetupAgent:
async def create_workflow(self, workflow_json: dict) -> dict:
"""Create or update a workflow."""
return await self._request("POST", "workflows", json=workflow_json)
return await self._request("POST", "workflows", json=self._workflow_payload(workflow_json))
def _workflow_payload(self, workflow_json: dict) -> dict:
"""Return a workflow payload without server-managed read-only fields."""
payload = dict(workflow_json)
payload.pop("active", None)
payload.pop("id", None)
payload.pop("createdAt", None)
payload.pop("updatedAt", None)
payload.pop("versionId", None)
return payload
async def _update_workflow_via_put(self, workflow_id: str, workflow_json: dict) -> dict:
"""Fallback update path for n8n instances that only support PUT."""
return await self._request("PUT", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict:
"""Update an existing workflow."""
return await self._request("PATCH", f"workflows/{workflow_id}", json=workflow_json)
result = await self._request("PATCH", f"workflows/{workflow_id}", json=self._workflow_payload(workflow_json))
if result.get("status_code") == 405:
fallback = await self._update_workflow_via_put(workflow_id, workflow_json)
if not fallback.get("error") and isinstance(fallback, dict):
fallback.setdefault("method", "PUT")
return fallback
return result
async def enable_workflow(self, workflow_id: str) -> dict:
"""Enable a workflow."""
@@ -96,6 +252,11 @@ class N8NSetupAgent:
if result.get("error"):
fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True})
if fallback.get("error"):
if fallback.get("status_code") == 405:
put_fallback = await self._request("PUT", f"workflows/{workflow_id}", json={"active": True})
if put_fallback.get("error"):
return put_fallback
return {"success": True, "id": workflow_id, "method": "put"}
return fallback
return {"success": True, "id": workflow_id, "method": "patch"}
return {"success": True, "id": workflow_id, "method": "activate"}
@@ -119,7 +280,6 @@ class N8NSetupAgent:
normalized_path = webhook_path.strip().strip("/") or "telegram"
return {
"name": "Telegram to AI Software Factory",
"active": False,
"settings": {"executionOrder": "v1"},
"nodes": [
{
@@ -188,7 +348,6 @@ class N8NSetupAgent:
"""Build a production Telegram Trigger based workflow."""
return {
"name": "Telegram to AI Software Factory",
"active": False,
"settings": {"executionOrder": "v1"},
"nodes": [
{
@@ -304,12 +463,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 +485,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 +501,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 +514,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 +528,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",

View File

@@ -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(
"""
<style>
@@ -81,6 +106,105 @@ def create_dashboard():
"""
)
def _render_n8n_error_dialog(result: dict) -> None:
"""Render a detailed n8n failure dialog."""
health = result.get('health', {}) if isinstance(result.get('health'), dict) else {}
checks = result.get('checks') or health.get('checks') or []
details = result.get('details') if isinstance(result.get('details'), dict) else {}
with ui.dialog() as dialog, ui.card().classes('factory-panel q-pa-lg').style('max-width: 840px; width: min(92vw, 840px);'):
ui.label('n8n provisioning failed').style('font-size: 1.35rem; font-weight: 800; color: #5c2d1f;')
ui.label(result.get('message', 'No error message returned.')).classes('factory-muted')
if result.get('suggestion') or health.get('suggestion'):
ui.label(result.get('suggestion') or health.get('suggestion')).classes('factory-chip q-mt-sm')
if checks:
ui.label('Health checks').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
for check in checks:
status = 'OK' if check.get('ok') else 'FAIL'
message = check.get('message') or 'No detail available'
ui.markdown(
f"- **{escape(check.get('name', 'check'))}** · {status} · {escape(str(check.get('status_code') or 'n/a'))} · {escape(check.get('url') or 'unknown url')}"
)
ui.label(message).classes('factory-muted')
if details:
ui.label('API response').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
ui.label(str(details)).classes('factory-code')
with ui.row().classes('justify-end w-full q-mt-md'):
ui.button('Close', on_click=dialog.close).props('unelevated color=dark')
dialog.open()
def _render_health_panels() -> None:
"""Render application and n8n health panels."""
runtime = get_database_runtime_summary()
n8n_health = _load_n8n_health_snapshot()
with ui.grid(columns=2).classes('w-full gap-4'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Application Health').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
rows = [
('Status', 'healthy'),
('Database Backend', runtime['backend']),
('Database Target', runtime['target']),
('Database Name', runtime['database']),
('Backend URL', settings.backend_public_url),
('Projects Root', str(settings.projects_root)),
]
for label, value in rows:
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label(label).classes('factory-muted')
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('n8n Connection Status').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
status_label = n8n_health.get('status', 'unknown').upper()
ui.label(status_label).classes('factory-chip')
ui.label(n8n_health.get('message', 'No n8n status available.')).classes('factory-muted q-mt-sm')
rows = [
('API URL', n8n_health.get('api_url') or 'Not configured'),
('Auth Configured', 'yes' if n8n_health.get('auth_configured') else 'no'),
('Checked Via', n8n_health.get('checked_via') or 'none'),
]
if n8n_health.get('workflow_count') is not None:
rows.append(('Workflow Count', str(n8n_health['workflow_count'])))
for label, value in rows:
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label(label).classes('factory-muted')
ui.label(str(value)).style('font-weight: 600; color: #3a281a;')
if n8n_health.get('suggestion'):
ui.label(n8n_health['suggestion']).classes('factory-chip q-mt-md')
checks = n8n_health.get('checks', [])
if checks:
ui.label('Checks').style('font-size: 1rem; font-weight: 700; color: #3a281a; margin-top: 12px;')
for check in checks:
status = 'OK' if check.get('ok') else 'FAIL'
ui.markdown(
f"- **{escape(check.get('name', 'check'))}** · {status} · {escape(str(check.get('status_code') or 'n/a'))} · {escape(check.get('url') or 'unknown url')}"
)
if check.get('message'):
ui.label(check['message']).classes('factory-muted')
def create_health_page() -> None:
"""Create a dedicated health page for runtime diagnostics."""
_add_dashboard_styles()
with ui.column().classes('factory-shell w-full gap-4 q-pa-lg'):
with ui.card().classes('factory-panel w-full q-pa-lg'):
with ui.row().classes('items-center justify-between w-full'):
with ui.column().classes('gap-1'):
ui.label('Factory Health').style('font-size: 2rem; font-weight: 800; color: #302116;')
ui.label('Current application and n8n connectivity diagnostics.').classes('factory-muted')
with ui.row().classes('items-center gap-2'):
ui.link('Back to Dashboard', '/')
ui.link('Refresh Health', '/health-ui')
_render_health_panels()
def create_dashboard():
"""Create the main NiceGUI dashboard."""
_add_dashboard_styles()
async def setup_n8n_workflow_action() -> None:
api_url = _resolve_n8n_api_url()
if not api_url:
@@ -103,6 +227,8 @@ def create_dashboard():
message=result.get('message', str(result)),
)
if result.get('status') == 'error':
_render_n8n_error_dialog(result)
ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative')
dashboard_body.refresh()
@@ -158,11 +284,12 @@ def create_dashboard():
ui.label(str(value)).style('font-size: 2.1rem; font-weight: 800; margin-top: 6px;')
ui.label(subtitle).style('font-size: 0.9rem; opacity: 0.78; margin-top: 8px;')
tabs = ui.tabs().classes('w-full')
overview_tab = ui.tab('Overview')
projects_tab = ui.tab('Projects')
trace_tab = ui.tab('Prompt Trace')
system_tab = ui.tab('System')
with ui.tabs().classes('w-full') as tabs:
overview_tab = ui.tab('Overview')
projects_tab = ui.tab('Projects')
trace_tab = ui.tab('Prompt Trace')
system_tab = ui.tab('System')
health_tab = ui.tab('Health')
with ui.tab_panels(tabs, value=overview_tab).classes('w-full'):
with ui.tab_panel(overview_tab):
@@ -296,6 +423,13 @@ def create_dashboard():
for endpoint in endpoints:
ui.label(endpoint).classes('factory-code q-mt-sm')
with ui.tab_panel(health_tab):
with ui.card().classes('factory-panel q-pa-lg q-mb-md'):
ui.label('Health and Diagnostics').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
ui.label('Use this page to verify runtime configuration, n8n API connectivity, and likely causes of provisioning failures.').classes('factory-muted')
ui.link('Open dedicated health page', '/health-ui')
_render_health_panels()
dashboard_body()
ui.timer(10.0, dashboard_body.refresh)

View File

@@ -10,9 +10,9 @@ from fastapi.responses import RedirectResponse
from nicegui import app, ui
try:
from .dashboard_ui import create_dashboard
from .dashboard_ui import create_dashboard, create_health_page
except ImportError:
from dashboard_ui import create_dashboard
from dashboard_ui import create_dashboard, create_health_page
def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
@@ -38,6 +38,10 @@ def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None:
def show() -> None:
render_dashboard_page()
@ui.page('/health-ui')
def health_ui() -> None:
create_health_page()
@fastapi_app.get('/dashboard', include_in_schema=False)
def dashboard_redirect() -> RedirectResponse:
return RedirectResponse(url='/', status_code=307)

View File

@@ -1,3 +0,0 @@
#!/bin/bash
echo "Hello world"

View File

@@ -323,10 +323,16 @@ async def get_n8n_health():
"""Check whether the configured n8n instance is reachable."""
api_url = _resolve_n8n_api_url()
if not api_url:
return {'status': 'error', 'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured'}
return {
'status': 'error',
'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured.',
'api_url': '',
'auth_configured': bool(database_module.settings.n8n_api_key),
'checks': [],
'suggestion': 'Set N8N_API_URL to the base n8n address before provisioning workflows.',
}
agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
result = await agent.health_check()
return {'status': 'ok' if not result.get('error') else 'error', 'data': result}
return await agent.health_check()
@app.post('/n8n/setup')