8 Commits
0.5.0 ... 0.6.2

Author SHA1 Message Date
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
11 changed files with 442 additions and 48 deletions

View File

@@ -4,6 +4,35 @@ Changelog
(unreleased) (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 - Feat(dashboard): expose repository urls refs NOISSUE. [Simon
Diesenreiter] Diesenreiter]
- Feat(factory): serve dashboard at root and create project repos refs - 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. # Optional legacy fixed-repository mode. Leave empty to create one repo per project.
GITEA_REPO= 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
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
@@ -90,6 +99,8 @@ docker-compose up -d
Check your gitea repository for generated PRs 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 ## API Endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |

View File

@@ -30,6 +30,8 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_chat_id TELEGRAM_CHAT_ID=your_chat_id
# PostgreSQL # 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_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_USER=ai_test POSTGRES_USER=ai_test

View File

@@ -1 +1 @@
0.5.0 0.6.2

View File

@@ -1,6 +1,8 @@
"""n8n setup agent for automatic webhook configuration.""" """n8n setup agent for automatic webhook configuration."""
import json import json
from urllib import error as urllib_error
from urllib import request as urllib_request
from typing import Optional from typing import Optional
try: try:
@@ -46,6 +48,113 @@ class N8NSetupAgent:
headers["X-N8N-API-KEY"] = self.webhook_token headers["X-N8N-API-KEY"] = self.webhook_token
return headers 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: async def _request(self, method: str, path: str, **kwargs) -> dict:
"""Send a request to n8n and normalize the response.""" """Send a request to n8n and normalize the response."""
import aiohttp import aiohttp
@@ -62,15 +171,42 @@ class N8NSetupAgent:
payload = {"text": await resp.text()} payload = {"text": await resp.text()}
if 200 <= resp.status < 300: if 200 <= resp.status < 300:
if isinstance(payload, dict): return self._normalize_success(method, url, resp.status, payload)
payload.setdefault("status_code", resp.status)
return payload
return {"data": payload, "status_code": resp.status}
message = payload.get("message") if isinstance(payload, dict) else str(payload) return self._normalize_error(method, url, resp.status, payload)
return {"error": f"Status {resp.status}: {message}", "status_code": resp.status, "payload": payload}
except Exception as e: 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]: async def get_workflow(self, workflow_name: str) -> Optional[dict]:
"""Get a workflow by name.""" """Get a workflow by name."""
@@ -304,12 +440,14 @@ class N8NSetupAgent:
async def health_check(self) -> dict: async def health_check(self) -> dict:
"""Check n8n API health.""" """Check n8n API health."""
result = await self._request("GET", f"{self.api_url}/healthz") result = await self._request("GET", f"{self.api_url}/healthz")
if result.get("error"): fallback = await self._request("GET", "workflows")
fallback = await self._request("GET", "workflows") return self._build_health_result(result, fallback)
if fallback.get("error"):
return fallback def health_check_sync(self) -> dict:
return {"status": "ok", "checked_via": "workflows"} """Synchronously check n8n API health for UI rendering."""
return {"status": "ok", "checked_via": "healthz"} 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( async def setup(
self, self,
@@ -324,7 +462,13 @@ class N8NSetupAgent:
# First, verify n8n is accessible # First, verify n8n is accessible
health = await self.health_check() health = await self.health_check()
if health.get("error"): 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_backend_url = backend_url or f"{settings.backend_public_url}/generate"
effective_bot_token = telegram_bot_token or settings.telegram_bot_token effective_bot_token = telegram_bot_token or settings.telegram_bot_token
@@ -334,7 +478,7 @@ class N8NSetupAgent:
if trigger_mode: if trigger_mode:
credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name) credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name)
if credential.get("error"): if credential.get("error"):
return {"status": "error", "message": credential["error"]} return {"status": "error", "message": credential["error"], "details": credential}
workflow = self.build_telegram_trigger_workflow( workflow = self.build_telegram_trigger_workflow(
backend_url=effective_backend_url, backend_url=effective_backend_url,
credential_name=effective_credential_name, credential_name=effective_credential_name,
@@ -347,7 +491,7 @@ class N8NSetupAgent:
existing = await self.get_workflow(workflow["name"]) existing = await self.get_workflow(workflow["name"])
if isinstance(existing, dict) and existing.get("error"): 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 workflow_id = None
if existing and existing.get("id"): if existing and existing.get("id"):
@@ -361,12 +505,12 @@ class N8NSetupAgent:
workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None
if isinstance(result, dict) and result.get("error"): 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", "")) workflow_id = workflow_id or str(result.get("id", ""))
enable_result = await self.enable_workflow(workflow_id) enable_result = await self.enable_workflow(workflow_id)
if enable_result.get("error"): 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 { return {
"status": "success", "status": "success",

View File

@@ -66,6 +66,32 @@ class Settings(BaseSettings):
DB_POOL_RECYCLE: int = 3600 DB_POOL_RECYCLE: int = 3600
DB_POOL_TIMEOUT: int = 30 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 @property
def pool(self) -> dict: def pool(self) -> dict:
"""Get database pool configuration.""" """Get database pool configuration."""
@@ -79,8 +105,10 @@ class Settings(BaseSettings):
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Get database connection URL.""" """Get database connection URL."""
if self.USE_SQLITE: if self.use_sqlite:
return f"sqlite:///{self.SQLITE_DB_PATH}" return f"sqlite:///{self.SQLITE_DB_PATH}"
if self.postgres_url:
return self.postgres_url
return ( return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
@@ -89,8 +117,10 @@ class Settings(BaseSettings):
@property @property
def test_database_url(self) -> str: def test_database_url(self) -> str:
"""Get test database connection URL.""" """Get test database connection URL."""
if self.USE_SQLITE: if self.use_sqlite:
return f"sqlite:///{self.SQLITE_DB_PATH}" return f"sqlite:///{self.SQLITE_DB_PATH}"
if self.postgres_url:
return self.postgres_url
return ( return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}" f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_TEST_DB}"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from contextlib import closing from contextlib import closing
from html import escape
from nicegui import ui from nicegui import ui
@@ -10,12 +11,12 @@ try:
from .agents.database_manager import DatabaseManager from .agents.database_manager import DatabaseManager
from .agents.n8n_setup import N8NSetupAgent from .agents.n8n_setup import N8NSetupAgent
from .config import settings 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: except ImportError:
from agents.database_manager import DatabaseManager from agents.database_manager import DatabaseManager
from agents.n8n_setup import N8NSetupAgent from agents.n8n_setup import N8NSetupAgent
from config import settings 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: def _resolve_n8n_api_url() -> str:
@@ -65,8 +66,32 @@ def _load_dashboard_snapshot() -> dict:
return {'error': f'Database error: {exc}'} return {'error': f'Database error: {exc}'}
def create_dashboard(): def _load_n8n_health_snapshot() -> dict:
"""Create the main NiceGUI dashboard.""" """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( ui.add_head_html(
""" """
<style> <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: async def setup_n8n_workflow_action() -> None:
api_url = _resolve_n8n_api_url() api_url = _resolve_n8n_api_url()
if not api_url: if not api_url:
@@ -103,6 +227,8 @@ def create_dashboard():
message=result.get('message', str(result)), 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') ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative')
dashboard_body.refresh() 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(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;') 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') overview_tab = ui.tab('Overview')
projects_tab = ui.tab('Projects') projects_tab = ui.tab('Projects')
trace_tab = ui.tab('Prompt Trace') trace_tab = ui.tab('Prompt Trace')
system_tab = ui.tab('System') system_tab = ui.tab('System')
health_tab = ui.tab('Health')
with ui.tab_panels(tabs, value=overview_tab).classes('w-full'): with ui.tab_panels(tabs, value=overview_tab).classes('w-full'):
with ui.tab_panel(overview_tab): with ui.tab_panel(overview_tab):
@@ -296,6 +423,13 @@ def create_dashboard():
for endpoint in endpoints: for endpoint in endpoints:
ui.label(endpoint).classes('factory-code q-mt-sm') 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() dashboard_body()
ui.timer(10.0, dashboard_body.refresh) ui.timer(10.0, dashboard_body.refresh)

View File

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

View File

@@ -10,9 +10,9 @@ from fastapi.responses import RedirectResponse
from nicegui import app, ui from nicegui import app, ui
try: try:
from .dashboard_ui import create_dashboard from .dashboard_ui import create_dashboard, create_health_page
except ImportError: 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: 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: def show() -> None:
render_dashboard_page() render_dashboard_page()
@ui.page('/health-ui')
def health_ui() -> None:
create_health_page()
@fastapi_app.get('/dashboard', include_in_schema=False) @fastapi_app.get('/dashboard', include_in_schema=False)
def dashboard_redirect() -> RedirectResponse: def dashboard_redirect() -> RedirectResponse:
return RedirectResponse(url='/', status_code=307) 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 __future__ import annotations
from contextlib import asynccontextmanager
import json import json
import re import re
from pathlib import Path from pathlib import Path
@@ -42,7 +43,18 @@ except ImportError:
__version__ = "0.0.1" __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)] DbSession = Annotated[Session, Depends(database_module.get_db)]
PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+") PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+")
@@ -178,9 +190,12 @@ def read_api_info():
@app.get('/health') @app.get('/health')
def health_check(): def health_check():
"""Health check endpoint.""" """Health check endpoint."""
runtime = database_module.get_database_runtime_summary()
return { return {
'status': 'healthy', '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.""" """Check whether the configured n8n instance is reachable."""
api_url = _resolve_n8n_api_url() api_url = _resolve_n8n_api_url()
if not 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) agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key)
result = await agent.health_check() return await agent.health_check()
return {'status': 'ok' if not result.get('error') else 'error', 'data': result}
@app.post('/n8n/setup') @app.post('/n8n/setup')