Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2829caa02 | |||
| d4b280cf75 | |||
| 806db8537b | |||
| 360ed5c6f3 | |||
| 4b9eb2f359 | |||
| ebfcfb969a | |||
| 56b05eb686 | |||
| 59a7e9787e |
29
HISTORY.md
29
HISTORY.md
@@ -4,6 +4,35 @@ Changelog
|
||||
|
||||
(unreleased)
|
||||
------------
|
||||
|
||||
Fix
|
||||
~~~
|
||||
- Fix Quasar layout issues, refs NOISSUE. [Simon Diesenreiter]
|
||||
|
||||
|
||||
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
|
||||
|
||||
11
README.md
11
README.md
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.5.0
|
||||
0.6.2
|
||||
|
||||
@@ -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"}
|
||||
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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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')
|
||||
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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Hello world"
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user