12 Commits
0.5.0 ... 0.6.4

Author SHA1 Message Date
b9faac8d16 release: version 0.6.4 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 52s
Upload Python Package / deploy (push) Successful in 2m14s
2026-04-10 21:47:54 +02:00
80d7716e65 fix: add Telegram helper functions, refs NOISSUE 2026-04-10 21:47:50 +02:00
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
ebfcfb969a release: version 0.6.0 🚀
All checks were successful
Upload Python Package / Create Release (push) Successful in 17s
Upload Python Package / deploy (push) Successful in 42s
2026-04-10 20:43:36 +02:00
56b05eb686 feat(api): expose database target in health refs NOISSUE 2026-04-10 20:39:36 +02:00
59a7e9787e fix(db): prefer postgres config in production refs NOISSUE 2026-04-10 20:37:31 +02:00
12 changed files with 603 additions and 60 deletions

View File

@@ -4,6 +4,57 @@ Changelog
(unreleased)
------------
Fix
~~~
- Add Telegram helper functions, refs NOISSUE. [Simon Diesenreiter]
0.6.3 (2026-04-10)
------------------
Fix
~~~
- N8n workflow generation, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
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
Diesenreiter]
0.5.0 (2026-04-10)
------------------
- Feat(dashboard): expose repository urls refs NOISSUE. [Simon
Diesenreiter]
- Feat(factory): serve dashboard at root and create project repos refs

View File

@@ -54,6 +54,15 @@ GITEA_OWNER=ai-software-factory
# Optional legacy fixed-repository mode. Leave empty to create one repo per project.
GITEA_REPO=
# Database
# In production, provide PostgreSQL settings. They take precedence over the SQLite default.
# Setting USE_SQLITE=false is still supported if you want to make the choice explicit.
POSTGRES_HOST=postgres.yourserver.com
POSTGRES_PORT=5432
POSTGRES_USER=ai_software_factory
POSTGRES_PASSWORD=change-me
POSTGRES_DB=ai_software_factory
# n8n
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
@@ -90,6 +99,8 @@ docker-compose up -d
Check your gitea repository for generated PRs
If you deploy the container with PostgreSQL environment variables set, the service now selects PostgreSQL automatically even though SQLite remains the default for local/test usage.
## API Endpoints
| Endpoint | Method | Description |

View File

@@ -30,6 +30,8 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_chat_id
# PostgreSQL
# In production, provide PostgreSQL settings below. They now take precedence over the SQLite default.
# You can also set USE_SQLITE=false explicitly if you want the intent to be obvious.
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=ai_test

View File

@@ -1 +1 @@
0.5.0
0.6.4

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

@@ -1,8 +1,6 @@
"""Telegram bot integration for n8n webhook."""
import asyncio
import json
import re
from typing import Optional
@@ -12,6 +10,62 @@ class TelegramHandler:
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
self.api_url = "https://api.telegram.org/bot"
def build_prompt_guide_message(self, backend_url: str | None = None) -> str:
"""Build a Telegram message explaining the expected prompt format."""
lines = [
"AI Software Factory is listening in this chat.",
"",
"Send prompts in this format:",
"Name: Inventory Portal",
"Description: Internal tool for stock management and purchase tracking",
"Features:",
"- role-based login",
"- inventory dashboard",
"- purchase order workflow",
"Tech Stack:",
"- fastapi",
"- postgresql",
"- nicegui",
]
if backend_url:
lines.extend(["", f"Backend target: {backend_url}"])
return "\n".join(lines)
async def send_message(self, bot_token: str, chat_id: str | int, text: str) -> dict:
"""Send a direct Telegram message using the configured bot."""
if not bot_token:
return {"status": "error", "message": "Telegram bot token is not configured"}
if chat_id in (None, ""):
return {"status": "error", "message": "Telegram chat id is not configured"}
api_endpoint = f"{self.api_url}{bot_token}/sendMessage"
try:
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
api_endpoint,
json={
"chat_id": str(chat_id),
"text": text,
},
) as resp:
payload = await resp.json()
if 200 <= resp.status < 300 and payload.get("ok"):
return {
"status": "success",
"message": "Telegram prompt guide sent successfully",
"payload": payload,
}
description = payload.get("description") or payload.get("message") or str(payload)
return {
"status": "error",
"message": f"Telegram API returned {resp.status}: {description}",
"payload": payload,
}
except Exception as exc:
return {"status": "error", "message": str(exc)}
async def handle_message(self, message_data: dict) -> dict:
"""Handle incoming Telegram message."""

View File

@@ -66,6 +66,32 @@ class Settings(BaseSettings):
DB_POOL_RECYCLE: int = 3600
DB_POOL_TIMEOUT: int = 30
@property
def postgres_url(self) -> str:
"""Get PostgreSQL URL with trimmed whitespace."""
return (self.POSTGRES_URL or "").strip()
@property
def postgres_env_configured(self) -> bool:
"""Whether PostgreSQL was explicitly configured via environment variables."""
if self.postgres_url:
return True
postgres_env_keys = (
"POSTGRES_HOST",
"POSTGRES_PORT",
"POSTGRES_USER",
"POSTGRES_PASSWORD",
"POSTGRES_DB",
)
return any(bool(os.environ.get(key, "").strip()) for key in postgres_env_keys)
@property
def use_sqlite(self) -> bool:
"""Whether SQLite should be used as the active database backend."""
if not self.USE_SQLITE:
return False
return not self.postgres_env_configured
@property
def pool(self) -> dict:
"""Get database pool configuration."""
@@ -79,8 +105,10 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Get database connection URL."""
if self.USE_SQLITE:
if self.use_sqlite:
return f"sqlite:///{self.SQLITE_DB_PATH}"
if self.postgres_url:
return self.postgres_url
return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
@@ -89,8 +117,10 @@ class Settings(BaseSettings):
@property
def test_database_url(self) -> str:
"""Get test database connection URL."""
if self.USE_SQLITE:
if self.use_sqlite:
return f"sqlite:///{self.SQLITE_DB_PATH}"
if self.postgres_url:
return self.postgres_url
return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}"

View File

@@ -3,19 +3,22 @@
from __future__ import annotations
from contextlib import closing
from html import escape
from nicegui import ui
from nicegui import app, ui
try:
from .agents.database_manager import DatabaseManager
from .agents.n8n_setup import N8NSetupAgent
from .agents.telegram import TelegramHandler
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 agents.telegram import TelegramHandler
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 +68,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 +108,115 @@ 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'),
('Telegram Chat ID', settings.telegram_chat_id or 'Not configured'),
]
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()
active_tab_key = 'dashboard.active_tab'
def _selected_tab_name() -> str:
"""Return the persisted active dashboard tab."""
return app.storage.user.get(active_tab_key, 'overview')
def _store_selected_tab(event) -> None:
"""Persist the active dashboard tab across refreshes."""
app.storage.user[active_tab_key] = event.value or 'overview'
async def setup_n8n_workflow_action() -> None:
api_url = _resolve_n8n_api_url()
if not api_url:
@@ -103,9 +239,39 @@ 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()
async def send_telegram_prompt_guide_action() -> None:
if not settings.telegram_bot_token:
ui.notify('Configure TELEGRAM_BOT_TOKEN first', color='negative')
return
if not settings.telegram_chat_id:
ui.notify('Configure TELEGRAM_CHAT_ID to message the prompt channel', color='negative')
return
handler = TelegramHandler(settings.n8n_webhook_url or _resolve_n8n_api_url())
message = handler.build_prompt_guide_message(settings.backend_public_url)
result = await handler.send_message(
bot_token=settings.telegram_bot_token,
chat_id=settings.telegram_chat_id,
text=message,
)
db = get_db_sync()
if db is not None:
with closing(db):
DatabaseManager(db).log_system_event(
component='telegram',
level='INFO' if result.get('status') == 'success' else 'ERROR',
message=result.get('message', str(result)),
)
ui.notify(result.get('message', 'Telegram message sent'), color='positive' if result.get('status') == 'success' else 'negative')
dashboard_body.refresh()
def init_db_action() -> None:
result = init_db()
ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative')
@@ -144,6 +310,7 @@ def create_dashboard():
ui.button('Refresh', on_click=dashboard_body.refresh).props('outline')
ui.button('Initialize DB', on_click=init_db_action).props('unelevated color=dark')
ui.button('Provision n8n Workflow', on_click=setup_n8n_workflow_action).props('unelevated color=accent')
ui.button('Message Prompt Channel', on_click=send_telegram_prompt_guide_action).props('outline color=secondary')
with ui.grid(columns=4).classes('w-full gap-4'):
metrics = [
@@ -158,14 +325,16 @@ 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')
selected_tab = _selected_tab_name()
with ui.tabs(value=selected_tab, on_change=_store_selected_tab).classes('w-full') as tabs:
ui.tab('Overview').props('name=overview')
ui.tab('Projects').props('name=projects')
ui.tab('Prompt Trace').props('name=trace')
ui.tab('System').props('name=system')
ui.tab('Health').props('name=health')
with ui.tab_panels(tabs, value=overview_tab).classes('w-full'):
with ui.tab_panel(overview_tab):
with ui.tab_panels(tabs, value=selected_tab).classes('w-full'):
with ui.tab_panel('overview'):
with ui.grid(columns=2).classes('w-full gap-4'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Project Pipeline').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
@@ -195,7 +364,7 @@ def create_dashboard():
ui.label(label).classes('factory-muted')
ui.label(value).style('font-weight: 600; color: #3a281a;')
with ui.tab_panel(projects_tab):
with ui.tab_panel('projects'):
if not projects:
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('No project data available yet.').classes('factory-muted')
@@ -249,7 +418,7 @@ def create_dashboard():
else:
ui.label('No audit events yet.').classes('factory-muted')
with ui.tab_panel(trace_tab):
with ui.tab_panel('trace'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('Prompt to Code Correlation').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
ui.label('Each prompt entry is linked to the generated files recorded after that prompt for the same project.').classes('factory-muted')
@@ -270,7 +439,7 @@ def create_dashboard():
else:
ui.label('No prompt traces recorded yet.').classes('factory-muted')
with ui.tab_panel(system_tab):
with ui.tab_panel('system'):
with ui.grid(columns=2).classes('w-full gap-4'):
with ui.card().classes('factory-panel q-pa-lg'):
ui.label('System Logs').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
@@ -296,6 +465,21 @@ def create_dashboard():
for endpoint in endpoints:
ui.label(endpoint).classes('factory-code q-mt-sm')
with ui.tab_panel('health'):
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')
with ui.card().classes('factory-panel q-pa-lg q-mb-md'):
ui.label('Telegram Prompt Channel').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
ui.label('Send a guide message into the same Telegram chat/channel where the bot is expected to receive prompts.').classes('factory-muted')
with ui.row().classes('justify-between w-full q-mt-sm'):
ui.label('Configured Chat ID').classes('factory-muted')
ui.label(settings.telegram_chat_id or 'Not configured').style('font-weight: 600; color: #3a281a;')
with ui.row().classes('items-center gap-2 q-mt-md'):
ui.button('Send Prompt Guide', on_click=send_telegram_prompt_guide_action).props('unelevated color=secondary')
_render_health_panels()
dashboard_body()
ui.timer(10.0, dashboard_body.refresh)

View File

@@ -2,6 +2,7 @@
from collections.abc import Generator
from pathlib import Path
from urllib.parse import urlparse
from alembic import command
from alembic.config import Config
@@ -17,10 +18,31 @@ except ImportError:
from models import Base
def get_database_runtime_summary() -> dict[str, str]:
"""Return a human-readable summary of the effective database backend."""
if settings.use_sqlite:
db_path = str(Path(settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db").expanduser().resolve())
return {
"backend": "sqlite",
"target": db_path,
"database": db_path,
}
parsed = urlparse(settings.database_url)
database_name = parsed.path.lstrip("/") or "unknown"
host = parsed.hostname or "unknown-host"
port = str(parsed.port or 5432)
return {
"backend": parsed.scheme.split("+", 1)[0] or "postgresql",
"target": f"{host}:{port}/{database_name}",
"database": database_name,
}
def get_engine() -> Engine:
"""Create and return SQLAlchemy engine with connection pooling."""
# Use SQLite for tests, PostgreSQL for production
if settings.USE_SQLITE:
if settings.use_sqlite:
db_path = settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db"
Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
db_url = f"sqlite:///{db_path}"
@@ -31,7 +53,7 @@ def get_engine() -> Engine:
echo=settings.LOG_LEVEL == "DEBUG"
)
else:
db_url = settings.POSTGRES_URL or settings.database_url
db_url = settings.database_url
# PostgreSQL-specific configuration
engine = create_engine(
db_url,
@@ -43,7 +65,7 @@ def get_engine() -> Engine:
)
# Event listener for connection checkout (PostgreSQL only)
if not settings.USE_SQLITE:
if not settings.use_sqlite:
@event.listens_for(engine, "checkout")
def receive_checkout(dbapi_connection, connection_record, connection_proxy):
"""Log connection checkout for audit purposes."""
@@ -100,7 +122,7 @@ def get_alembic_config(database_url: str | None = None) -> Config:
alembic_ini = package_root / "alembic.ini"
config = Config(str(alembic_ini))
config.set_main_option("script_location", str(package_root / "alembic"))
config.set_main_option("sqlalchemy.url", database_url or (settings.database_url if not settings.USE_SQLITE else f"sqlite:///{settings.SQLITE_DB_PATH or '/tmp/ai_software_factory_test.db'}"))
config.set_main_option("sqlalchemy.url", database_url or settings.database_url)
return config
@@ -116,7 +138,7 @@ def run_migrations(database_url: str | None = None) -> dict:
def init_db() -> dict:
"""Initialize database tables and database if needed."""
if settings.USE_SQLITE:
if settings.use_sqlite:
result = run_migrations()
if result["status"] == "success":
print("SQLite database migrations applied successfully.")
@@ -131,7 +153,7 @@ def init_db() -> dict:
return {'status': 'error', 'message': f'Error: {str(e)}'}
else:
# PostgreSQL
db_url = settings.POSTGRES_URL or settings.database_url
db_url = settings.database_url
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
try:
@@ -180,7 +202,7 @@ def init_db() -> dict:
def drop_db() -> dict:
"""Drop all database tables (use with caution!)."""
if settings.USE_SQLITE:
if settings.use_sqlite:
engine = get_engine()
try:
Base.metadata.drop_all(bind=engine)
@@ -190,7 +212,7 @@ def drop_db() -> dict:
print(f"Error dropping SQLite tables: {str(e)}")
return {'status': 'error', 'message': str(e)}
else:
db_url = settings.POSTGRES_URL or settings.database_url
db_url = settings.database_url
db_name = db_url.split('/')[-1] if '/' in db_url else 'ai_software_factory'
try:

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

@@ -13,6 +13,7 @@ The NiceGUI frontend provides:
from __future__ import annotations
from contextlib import asynccontextmanager
import json
import re
from pathlib import Path
@@ -42,7 +43,18 @@ except ImportError:
__version__ = "0.0.1"
app = FastAPI()
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""Log resolved runtime configuration when the app starts."""
runtime = database_module.get_database_runtime_summary()
print(
f"Runtime configuration: database_backend={runtime['backend']} target={runtime['target']}"
)
yield
app = FastAPI(lifespan=lifespan)
DbSession = Annotated[Session, Depends(database_module.get_db)]
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
@@ -178,9 +190,12 @@ def read_api_info():
@app.get('/health')
def health_check():
"""Health check endpoint."""
runtime = database_module.get_database_runtime_summary()
return {
'status': 'healthy',
'database': 'sqlite' if database_module.settings.USE_SQLITE else 'postgresql',
'database': runtime['backend'],
'database_target': runtime['target'],
'database_name': runtime['database'],
}
@@ -308,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')