Release v4928.1.4.2 stable
This commit is contained in:
300
app/admin_ui/pages/system.py
Normal file
300
app/admin_ui/pages/system.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Settings and system health routes.
|
||||
|
||||
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
|
||||
legacy helpers to keep this refactor behavior-preserving.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
import app.admin_dashboard as legacy
|
||||
from app.admin_dashboard import * # noqa: F401,F403
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/system", response_class=HTMLResponse)
|
||||
async def system_health_page():
|
||||
metrics = get_system_health_metrics()
|
||||
backup = latest_backup_info()
|
||||
|
||||
timers_raw = shell_output(["systemctl", "list-timers", "--all", "--no-pager"])
|
||||
clientflow_timers = "\n".join(
|
||||
line for line in timers_raw.splitlines()
|
||||
if "clientflow" in line.lower()
|
||||
)
|
||||
|
||||
services = [
|
||||
"clientflow-api.service",
|
||||
"clientflow-outbox.timer",
|
||||
"clientflow-backup.timer",
|
||||
"clientflow-followups.timer",
|
||||
]
|
||||
|
||||
service_rows = ""
|
||||
for service in services:
|
||||
active = shell_output(["systemctl", "is-active", service])
|
||||
enabled = shell_output(["systemctl", "is-enabled", service])
|
||||
ok = active in ["active", "inactive"] if service.endswith(".service") else active == "active"
|
||||
if service == "clientflow-api.service":
|
||||
ok = active == "active"
|
||||
|
||||
service_rows += f"""
|
||||
<tr>
|
||||
<td><code>{esc(service)}</code></td>
|
||||
<td>{status_pill(ok, active)}</td>
|
||||
<td>{esc(enabled)}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
def n(key):
|
||||
return metrics.get(key) or 0
|
||||
|
||||
db_ok = True
|
||||
outbox_ok = n("outbox_failed") == 0
|
||||
webhook_ok = n("webhooks_errors_24h") == 0
|
||||
backup_ok = bool(backup.get("exists"))
|
||||
|
||||
body = f"""
|
||||
<style>
|
||||
.system-grid {{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4, minmax(180px, 1fr));
|
||||
gap:14px;
|
||||
margin-bottom:18px;
|
||||
}}
|
||||
.system-card {{
|
||||
background:white;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:16px;
|
||||
padding:16px;
|
||||
}}
|
||||
.system-card span {{
|
||||
color:#6b7280;
|
||||
font-size:13px;
|
||||
}}
|
||||
.system-card strong {{
|
||||
display:block;
|
||||
font-size:28px;
|
||||
margin-top:6px;
|
||||
}}
|
||||
.system-panel {{
|
||||
background:white;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:16px;
|
||||
padding:16px;
|
||||
margin-bottom:16px;
|
||||
}}
|
||||
.system-panel h2 {{
|
||||
margin-top:0;
|
||||
font-size:18px;
|
||||
}}
|
||||
.system-pill {{
|
||||
display:inline-block;
|
||||
border-radius:999px;
|
||||
padding:4px 9px;
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
}}
|
||||
.pill-ok {{
|
||||
background:#dcfce7;
|
||||
color:#166534;
|
||||
border:1px solid #86efac;
|
||||
}}
|
||||
.pill-bad {{
|
||||
background:#fee2e2;
|
||||
color:#991b1b;
|
||||
border:1px solid #fecaca;
|
||||
}}
|
||||
pre.system-pre {{
|
||||
background:#111827;
|
||||
color:#f9fafb;
|
||||
border-radius:12px;
|
||||
padding:12px;
|
||||
overflow:auto;
|
||||
font-size:12px;
|
||||
}}
|
||||
@media (max-width:1000px) {{
|
||||
.system-grid {{ grid-template-columns:1fr 1fr; }}
|
||||
}}
|
||||
|
||||
</style>
|
||||
|
||||
<p>
|
||||
<a href="/">← Voltar à visão geral</a>
|
||||
·
|
||||
<a href="/system">Sistema</a>
|
||||
<a href="/tasks">Kanban</a>
|
||||
</p>
|
||||
|
||||
<div class="system-grid">
|
||||
<div class="system-card">
|
||||
<span>API</span>
|
||||
<strong>{status_pill(True, "OK")}</strong>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<span>Base de dados</span>
|
||||
<strong>{status_pill(db_ok, "OK" if db_ok else "ERRO")}</strong>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<span>Backups</span>
|
||||
<strong>{status_pill(backup_ok, "OK" if backup_ok else "FALTA")}</strong>
|
||||
</div>
|
||||
<div class="system-card">
|
||||
<span>Webhooks erros 24h</span>
|
||||
<strong>{n("webhooks_errors_24h")}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-grid">
|
||||
<div class="system-card"><span>Tasks pendentes</span><strong>{n("tasks_pending")}</strong></div>
|
||||
<div class="system-card"><span>Tasks 24h</span><strong>{n("tasks_24h")}</strong></div>
|
||||
<div class="system-card"><span>Webhooks 24h</span><strong>{n("webhooks_24h")}</strong></div>
|
||||
<div class="system-card"><span>Outbox falhada</span><strong>{n("outbox_failed")}</strong></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Estado dos serviços/timers</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serviço</th>
|
||||
<th>Active</th>
|
||||
<th>Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{service_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Último backup</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Existe</th><td>{status_pill(backup_ok, "Sim" if backup_ok else "Não")}</td></tr>
|
||||
<tr><th>Ficheiro</th><td><code>{esc(backup.get("file"))}</code></td></tr>
|
||||
<tr><th>Tamanho</th><td>{esc(backup.get("size"))}</td></tr>
|
||||
<tr><th>Modificado</th><td>{esc(backup.get("mtime"))}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Timers ClientFlow</h2>
|
||||
<pre class="system-pre">{esc(clientflow_timers or "Sem timers ClientFlow encontrados.")}</pre>
|
||||
</div>
|
||||
|
||||
<div class="system-panel">
|
||||
<h2>Resumo técnico</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Total tasks</th><td>{n("tasks_total")}</td></tr>
|
||||
<tr><th>Tasks done</th><td>{n("tasks_done")}</td></tr>
|
||||
<tr><th>Tasks failed</th><td>{n("tasks_failed")}</td></tr>
|
||||
<tr><th>Webhooks ignorados 24h</th><td>{n("webhooks_ignored_24h")}</td></tr>
|
||||
<tr><th>Outbox pending</th><td>{n("outbox_pending")}</td></tr>
|
||||
<tr><th>Outbox sent</th><td>{n("outbox_sent")}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return layout("Sistema", "Estado técnico do ClientFlow e integrações.", body, "system")
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
@router.get("/configuracoes", response_class=HTMLResponse)
|
||||
async def settings_page():
|
||||
return RedirectResponse("/system", status_code=303)
|
||||
|
||||
|
||||
@router.get("/system/health", response_class=HTMLResponse)
|
||||
async def system_health_operational_page():
|
||||
health = get_system_health_summary()
|
||||
settings_info = health.get("settings") or {}
|
||||
timers = health.get("timers") or {}
|
||||
outbox = health.get("outbox") or {}
|
||||
documents = health.get("documents") or {}
|
||||
operational_metrics = health.get("operational_metrics") or {}
|
||||
|
||||
def m(key: str) -> int:
|
||||
try:
|
||||
return int(operational_metrics.get(key) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
timer_rows = ""
|
||||
for name, item in timers.items():
|
||||
ok = bool(item.get("ok"))
|
||||
chip = "cf-chip-green" if ok else "cf-chip-orange"
|
||||
timer_rows += f"""
|
||||
<tr><td>{esc(name)}</td><td><code>{esc(item.get('unit') or '—')}</code></td><td><span class="cf-chip {chip}">{esc(item.get('active_state') or 'unknown')}</span></td></tr>
|
||||
"""
|
||||
|
||||
outbox_rows = ""
|
||||
for target, by_status in outbox.items():
|
||||
cells = "".join(f'<span class="cf-chip cf-chip-gray me-1">{esc(status)}: {esc(total)}</span>' for status, total in by_status.items())
|
||||
outbox_rows += f"<tr><td>{esc(target)}</td><td>{cells}</td></tr>"
|
||||
if not outbox_rows:
|
||||
outbox_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem dados de outbox.</td></tr>'
|
||||
|
||||
doc_rows = ""
|
||||
for kind, by_status in documents.items():
|
||||
cells = "".join(f'<span class="cf-chip cf-chip-gray me-1">{esc(status)}: {esc(total)}</span>' for status, total in by_status.items())
|
||||
doc_rows += f"<tr><td>{esc(kind)}</td><td>{cells}</td></tr>"
|
||||
if not doc_rows:
|
||||
doc_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem documentos.</td></tr>'
|
||||
|
||||
db_ok = bool((health.get("database") or {}).get("ok"))
|
||||
critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed")
|
||||
warning_count = m("ambiguous_opportunity_tasks") + m("open_opportunities_without_fiscal_customer") + m("active_incomplete_fiscal_customers") + m("products_missing_external_code")
|
||||
if critical_count:
|
||||
production_state = "Crítico"
|
||||
production_class = "critical"
|
||||
production_hint = "corrigir bloqueios antes de automatizar"
|
||||
elif warning_count:
|
||||
production_state = "Atenção"
|
||||
production_class = "warn"
|
||||
production_hint = "existem validações operacionais pendentes"
|
||||
else:
|
||||
production_state = "OK"
|
||||
production_class = "ok"
|
||||
production_hint = "sem bloqueios críticos visíveis"
|
||||
|
||||
body = f"""
|
||||
<section class="cf-health-status {production_class} mb-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div><div class="small text-secondary fw-bold text-uppercase">Estado para operação</div><strong class="h3 mb-0">{production_state}</strong><div class="small text-secondary">{production_hint}</div></div>
|
||||
<div class="d-flex flex-wrap gap-2"><span class="cf-chip cf-chip-red">Críticos: {esc(critical_count)}</span><span class="cf-chip cf-chip-orange">Atenção: {esc(warning_count)}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid">
|
||||
{kpi_card('Estado geral', esc(health.get('status')), None, 'OK' if db_ok else 'ver base de dados', 'bi-heart-pulse', 'cf-kpi-tone-green' if db_ok else 'cf-kpi-tone-red')}
|
||||
{kpi_card('Jasmin', 'ativo' if settings_info.get('jasmin_enabled') else 'inativo', None, f"{esc(settings_info.get('jasmin_company_key') or '—')} · {esc(settings_info.get('jasmin_quotation_serie') or '—')}", 'bi-receipt')}
|
||||
{kpi_card('Packlink', 'ativo' if settings_info.get('packlink_enabled') else 'inativo', None, f"serviço {esc(settings_info.get('packlink_default_service_id') or '—')}", 'bi-truck')}
|
||||
{kpi_card('Ambiente', esc(settings_info.get('env') or '—'), None, esc(settings_info.get('app_name') or 'ClientFlow'), 'bi-sliders')}
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid mt-3">
|
||||
{kpi_card('Tasks pendentes', esc(m('tasks_pending')), '/operations', f"done 24h: {esc(m('tasks_done_24h'))}", 'bi-list-task')}
|
||||
{kpi_card('Ambíguas', esc(m('ambiguous_opportunity_tasks')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-exclamation', 'cf-kpi-tone-orange' if m('ambiguous_opportunity_tasks') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Outbox stale', esc(m('outbox_stale') or m('outbox_processing_stale')), '/outbox?status=stale', f"modo: {esc(settings_info.get('outbox_stale_recovery_mode') or 'manual_only')}", 'bi-hourglass-split', 'cf-kpi-tone-red' if (m('outbox_stale') or m('outbox_processing_stale')) else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Ações operador 24h', esc(m('operator_actions_24h')), '/events', 'auditoria operacional', 'bi-shield-check')}
|
||||
</section>
|
||||
|
||||
<section class="cf-kpi-grid mt-3">
|
||||
{kpi_card('Oportunidades sem cliente fiscal', esc(m('open_opportunities_without_fiscal_customer')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-vcard', 'cf-kpi-tone-orange' if m('open_opportunities_without_fiscal_customer') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Clientes fiscais incompletos', esc(m('active_incomplete_fiscal_customers')), '/customers', 'em oportunidades ativas', 'bi-exclamation-diamond', 'cf-kpi-tone-orange' if m('active_incomplete_fiscal_customers') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Produtos sem código externo', esc(m('products_missing_external_code')), '/products', 'podem bloquear Jasmin', 'bi-box-seam', 'cf-kpi-tone-orange' if m('products_missing_external_code') else 'cf-kpi-tone-green')}
|
||||
{kpi_card('Último webhook Chatwoot', esc('—' if not m('seconds_since_last_chatwoot_webhook') else str(m('seconds_since_last_chatwoot_webhook')) + 's'), '/events', f"eventos: {esc(m('chatwoot_events_total'))}", 'bi-chat-dots')}
|
||||
</section>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Timers systemd</h2><div class="small text-secondary">Estado best-effort dos timers da outbox.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Integração</th><th>Unidade</th><th>Estado</th></tr></thead><tbody>{timer_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Outbox por sistema</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Sistema</th><th>Estados</th></tr></thead><tbody>{outbox_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Documentos comerciais</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Tipo</th><th>Estados</th></tr></thead><tbody>{doc_rows}</tbody></table></div></div></section></div>
|
||||
<div class="col-xl-6"><section class="card cf-card"><div class="card-body"><h2 class="cf-section-title mb-3">Base de dados</h2><p class="mb-2">Estado: <span class="cf-chip {'cf-chip-green' if db_ok else 'cf-chip-red'}">{'OK' if db_ok else 'Erro'}</span></p><pre class="cf-copy-box">{esc((health.get('database') or {}).get('error') or 'Sem erros reportados.')}</pre></div></section></div>
|
||||
</div>
|
||||
"""
|
||||
return layout("Saúde operacional", "Base de dados, integrações, timers e contadores", body, "system")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user