444 lines
24 KiB
Python
444 lines
24 KiB
Python
"""NiceGUI dashboard backed by real database state."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import closing
|
|
from html import escape
|
|
|
|
from nicegui import ui
|
|
|
|
try:
|
|
from .agents.database_manager import DatabaseManager
|
|
from .agents.n8n_setup import N8NSetupAgent
|
|
from .config import settings
|
|
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_database_runtime_summary, get_db_sync, init_db
|
|
|
|
|
|
def _resolve_n8n_api_url() -> str:
|
|
"""Resolve the configured n8n API base URL."""
|
|
if settings.n8n_api_url:
|
|
return settings.n8n_api_url
|
|
if settings.n8n_webhook_url:
|
|
return settings.n8n_webhook_url.split('/webhook', 1)[0].rstrip('/')
|
|
return ''
|
|
|
|
|
|
def _render_repository_block(repository: dict | None) -> None:
|
|
"""Render repository details and URL when available."""
|
|
if not repository:
|
|
ui.label('Repository URL not available yet.').classes('factory-muted')
|
|
return
|
|
|
|
owner = repository.get('owner') or 'unknown-owner'
|
|
name = repository.get('name') or 'unknown-repo'
|
|
mode = repository.get('mode') or 'project'
|
|
status = repository.get('status')
|
|
repo_url = repository.get('url')
|
|
|
|
with ui.column().classes('gap-1'):
|
|
with ui.row().classes('items-center gap-2'):
|
|
ui.label(f'{owner}/{name}').style('font-weight: 700; color: #2f241d;')
|
|
ui.label(mode).classes('factory-chip')
|
|
if status:
|
|
ui.label(status).classes('factory-chip')
|
|
if repo_url:
|
|
ui.link(repo_url, repo_url, new_tab=True).classes('factory-code')
|
|
else:
|
|
ui.label('Repository URL not available yet.').classes('factory-muted')
|
|
|
|
|
|
def _load_dashboard_snapshot() -> dict:
|
|
"""Load dashboard data from the database."""
|
|
db = get_db_sync()
|
|
if db is None:
|
|
return {'error': 'Database session could not be created'}
|
|
|
|
with closing(db):
|
|
manager = DatabaseManager(db)
|
|
try:
|
|
return manager.get_dashboard_snapshot(limit=8)
|
|
except Exception as exc:
|
|
return {'error': f'Database error: {exc}'}
|
|
|
|
|
|
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>
|
|
body { background: radial-gradient(circle at top, #f4efe7 0%, #e9e1d4 38%, #d7cec1 100%); }
|
|
.factory-shell { max-width: 1240px; margin: 0 auto; }
|
|
.factory-panel { background: rgba(255,255,255,0.78); backdrop-filter: blur(18px); border: 1px solid rgba(73,54,40,0.10); border-radius: 24px; box-shadow: 0 24px 60px rgba(84,55,24,0.14); }
|
|
.factory-kpi { background: linear-gradient(145deg, rgba(63,94,78,0.94), rgba(29,52,45,0.92)); color: #f8f3eb; border-radius: 18px; padding: 18px; min-height: 128px; }
|
|
.factory-muted { color: #745e4c; }
|
|
.factory-code { font-family: 'IBM Plex Mono', 'Fira Code', monospace; background: rgba(32,26,20,0.92); color: #f4efe7; border-radius: 14px; padding: 12px; white-space: pre-wrap; }
|
|
.factory-chip { background: rgba(173, 129, 82, 0.14); color: #6b4b2e; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
|
</style>
|
|
"""
|
|
)
|
|
|
|
|
|
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:
|
|
ui.notify('Configure N8N_API_URL or N8N_WEBHOOK_URL first', color='negative')
|
|
return
|
|
|
|
agent = N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key)
|
|
result = await agent.setup(
|
|
webhook_path='telegram',
|
|
backend_url=f'{settings.backend_public_url}/generate',
|
|
force_update=True,
|
|
)
|
|
|
|
db = get_db_sync()
|
|
if db is not None:
|
|
with closing(db):
|
|
DatabaseManager(db).log_system_event(
|
|
component='n8n',
|
|
level='INFO' if result.get('status') == 'success' else 'ERROR',
|
|
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()
|
|
|
|
def init_db_action() -> None:
|
|
result = init_db()
|
|
ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative')
|
|
dashboard_body.refresh()
|
|
|
|
@ui.refreshable
|
|
def dashboard_body() -> None:
|
|
snapshot = _load_dashboard_snapshot()
|
|
if snapshot.get('error'):
|
|
with ui.card().classes('factory-panel w-full max-w-4xl mx-auto q-pa-xl'):
|
|
ui.label('Dashboard unavailable').style('font-size: 1.5rem; font-weight: 700; color: #5c2d1f;')
|
|
ui.label(snapshot['error']).classes('factory-muted')
|
|
ui.button('Initialize Database', on_click=init_db_action).props('unelevated')
|
|
return
|
|
|
|
summary = snapshot['summary']
|
|
projects = snapshot['projects']
|
|
correlations = snapshot['correlations']
|
|
system_logs = snapshot['system_logs']
|
|
project_repository_map = {
|
|
project_bundle['project']['project_id']: {
|
|
'project_name': project_bundle['project']['project_name'],
|
|
'repository': project_bundle.get('repository') or project_bundle['project'].get('repository'),
|
|
}
|
|
for project_bundle in projects
|
|
if project_bundle.get('project')
|
|
}
|
|
|
|
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('AI Software Factory').style('font-size: 2.3rem; font-weight: 800; color: #302116;')
|
|
ui.label('Operational dashboard with project audit, prompt traces, and n8n controls.').classes('factory-muted')
|
|
with ui.row().classes('items-center gap-2'):
|
|
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')
|
|
|
|
with ui.grid(columns=4).classes('w-full gap-4'):
|
|
metrics = [
|
|
('Projects', summary['total_projects'], 'Tracked generation requests'),
|
|
('Completed', summary['completed_projects'], 'Finished project runs'),
|
|
('Prompts', summary['prompt_events'], 'Recorded originating prompts'),
|
|
('Code Changes', summary['code_changes'], 'Audited generated file writes'),
|
|
]
|
|
for title, value, subtitle in metrics:
|
|
with ui.card().classes('factory-kpi'):
|
|
ui.label(title).style('font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.8;')
|
|
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')
|
|
health_tab = ui.tab('Health')
|
|
|
|
with ui.tab_panels(tabs, value=overview_tab).classes('w-full'):
|
|
with ui.tab_panel(overview_tab):
|
|
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;')
|
|
if projects:
|
|
for project_bundle in projects[:4]:
|
|
project = project_bundle['project']
|
|
with ui.column().classes('gap-1 q-mt-md'):
|
|
with ui.row().classes('justify-between items-center'):
|
|
ui.label(project['project_name']).style('font-weight: 700; color: #2f241d;')
|
|
ui.label(project['status']).classes('factory-chip')
|
|
ui.linear_progress(value=(project['progress'] or 0) / 100, show_value=False).classes('w-full')
|
|
ui.label(project['message'] or 'No status message').classes('factory-muted')
|
|
else:
|
|
ui.label('No projects in the database yet.').classes('factory-muted')
|
|
|
|
with ui.card().classes('factory-panel q-pa-lg'):
|
|
ui.label('n8n and Runtime').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
|
rows = [
|
|
('Backend URL', settings.backend_public_url),
|
|
('Project Root', str(settings.projects_root)),
|
|
('n8n API URL', _resolve_n8n_api_url() or 'Not configured'),
|
|
('Running Projects', str(summary['running_projects'])),
|
|
('Errored Projects', str(summary['error_projects'])),
|
|
]
|
|
for label, value in rows:
|
|
with ui.row().classes('justify-between w-full q-mt-sm'):
|
|
ui.label(label).classes('factory-muted')
|
|
ui.label(value).style('font-weight: 600; color: #3a281a;')
|
|
|
|
with ui.tab_panel(projects_tab):
|
|
if not projects:
|
|
with ui.card().classes('factory-panel q-pa-lg'):
|
|
ui.label('No project data available yet.').classes('factory-muted')
|
|
for project_bundle in projects:
|
|
project = project_bundle['project']
|
|
with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'):
|
|
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
|
|
with ui.card().classes('q-pa-md'):
|
|
ui.label('Repository').style('font-weight: 700; color: #3a281a;')
|
|
_render_repository_block(project_bundle.get('repository') or project.get('repository'))
|
|
|
|
with ui.card().classes('q-pa-md'):
|
|
ui.label('Prompt').style('font-weight: 700; color: #3a281a;')
|
|
prompts = project_bundle.get('prompts', [])
|
|
if prompts:
|
|
prompt = prompts[0]
|
|
ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}")
|
|
ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}")
|
|
ui.label(prompt['prompt_text']).classes('factory-code')
|
|
else:
|
|
ui.label('No prompt recorded.').classes('factory-muted')
|
|
|
|
with ui.card().classes('q-pa-md'):
|
|
ui.label('Generated Changes').style('font-weight: 700; color: #3a281a;')
|
|
changes = project_bundle.get('code_changes', [])
|
|
if changes:
|
|
for change in changes:
|
|
with ui.row().classes('justify-between items-start w-full q-mt-sm'):
|
|
ui.label(change['file_path'] or 'unknown file').style('font-weight: 600; color: #2f241d;')
|
|
ui.label(change['action_type']).classes('factory-chip')
|
|
ui.label(change['diff_summary'] or change['details']).classes('factory-muted')
|
|
else:
|
|
ui.label('No code changes recorded.').classes('factory-muted')
|
|
|
|
with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'):
|
|
with ui.card().classes('q-pa-md'):
|
|
ui.label('Recent Logs').style('font-weight: 700; color: #3a281a;')
|
|
logs = project_bundle.get('logs', [])[:6]
|
|
if logs:
|
|
for log in logs:
|
|
ui.markdown(f"- {log['timestamp'] or 'n/a'} · {log['level']} · {log['message']}")
|
|
else:
|
|
ui.label('No project logs yet.').classes('factory-muted')
|
|
|
|
with ui.card().classes('q-pa-md'):
|
|
ui.label('Audit Trail').style('font-weight: 700; color: #3a281a;')
|
|
audits = project_bundle.get('audit_trail', [])[:6]
|
|
if audits:
|
|
for audit in audits:
|
|
ui.markdown(f"- {audit['timestamp'] or 'n/a'} · {audit['action']} · {audit['details']}")
|
|
else:
|
|
ui.label('No audit events yet.').classes('factory-muted')
|
|
|
|
with ui.tab_panel(trace_tab):
|
|
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')
|
|
if correlations:
|
|
for correlation in correlations:
|
|
correlation_project = project_repository_map.get(correlation['project_id'], {})
|
|
with ui.card().classes('q-pa-md q-mt-md'):
|
|
ui.label(correlation_project.get('project_name') or correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;')
|
|
_render_repository_block(correlation_project.get('repository'))
|
|
ui.label(correlation['prompt_text']).classes('factory-code q-mt-sm')
|
|
if correlation['changes']:
|
|
for change in correlation['changes']:
|
|
ui.markdown(
|
|
f"- **{change['file_path'] or 'unknown'}** · {change['change_type']} · {change['diff_summary'] or change['details']}"
|
|
)
|
|
else:
|
|
ui.label('No code changes correlated to this prompt yet.').classes('factory-muted')
|
|
else:
|
|
ui.label('No prompt traces recorded yet.').classes('factory-muted')
|
|
|
|
with ui.tab_panel(system_tab):
|
|
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;')
|
|
if system_logs:
|
|
for log in system_logs:
|
|
ui.markdown(f"- {log['timestamp'] or 'n/a'} · **{log['component']}** · {log['level']} · {log['message']}")
|
|
else:
|
|
ui.label('No system logs yet.').classes('factory-muted')
|
|
|
|
with ui.card().classes('factory-panel q-pa-lg'):
|
|
ui.label('Important Endpoints').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;')
|
|
endpoints = [
|
|
'/health',
|
|
'/generate',
|
|
'/projects',
|
|
'/audit/projects',
|
|
'/audit/prompts',
|
|
'/audit/changes',
|
|
'/audit/correlations',
|
|
'/n8n/health',
|
|
'/n8n/setup',
|
|
]
|
|
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)
|
|
|
|
|
|
def run_app(port=None, reload=False, browser=True, storage_secret=None):
|
|
"""Run the NiceGUI app."""
|
|
ui.run(title='AI Software Factory Dashboard', port=port, reload=reload, browser=browser, storage_secret=storage_secret)
|
|
|
|
|
|
if __name__ in {'__main__', '__console__'}:
|
|
create_dashboard()
|
|
run_app() |