feat: refine operational control panel pages
This commit is contained in:
+23
-9
@@ -1,6 +1,7 @@
|
||||
"""Server-rendered operator pages for the GTI Fiscal control panel."""
|
||||
|
||||
from urllib.parse import parse_qs
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
@@ -12,6 +13,7 @@ from .blocker_resolution import _company_key, resolve_blocker_with_context
|
||||
from .context_keys import ALLOWED_KEYS, ENUMS
|
||||
from .control_plane import blocker_detail, dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import create_session, current_session, require_csrf, verify_password
|
||||
from .panel_presentation import format_ui_datetime
|
||||
from .models import AdminUser, Agent, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
|
||||
from .jobs import create_job
|
||||
from .audit import record_audit
|
||||
@@ -28,6 +30,9 @@ def router(session_factory):
|
||||
values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), **(context or {})}
|
||||
return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code)
|
||||
|
||||
def query_base(**values):
|
||||
return urlencode({key: value for key, value in values.items() if value not in (None, "")})
|
||||
|
||||
@routes.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
return render(request, "login.html", {"error": None})
|
||||
@@ -75,14 +80,15 @@ def router(session_factory):
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents", response_class=HTMLResponse)
|
||||
def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None):
|
||||
def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None, sort: str = "display_name"):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health), "auth": auth_data, "search": search or "", "health_filter": health or ""})
|
||||
try:
|
||||
return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "query_base": query_base(search=search, health=health, sort=sort)})
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}", response_class=HTMLResponse)
|
||||
def agent_detail(request: Request, agent_id: int, tab: str = "overview"):
|
||||
def agent_detail(request: Request, agent_id: int, tab: str = "overview", event_page: int = 1, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
@@ -90,7 +96,10 @@ def router(session_factory):
|
||||
detail = next((item for item in result["items"] if item["agent_id"] == agent_id), None)
|
||||
if detail is None: return HTMLResponse("Agent not found", status_code=404)
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25), "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": list_panel_events(session, installation.id, page=1, page_size=25), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
|
||||
events = list_panel_events(session, installation.id, page=event_page, page_size=25, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code)
|
||||
for event in events:
|
||||
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
|
||||
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25), "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/reprocess")
|
||||
@@ -112,10 +121,15 @@ def router(session_factory):
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers", response_class=HTMLResponse)
|
||||
def blockers(request: Request, page: int = 1, blocker_type: str | None = None):
|
||||
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "blockers.html", {"result": list_blockers(session, page=page, page_size=50, blocker_type=blocker_type), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
|
||||
try:
|
||||
result = list_blockers(session, page=page, page_size=50, client=client, installation=installation, field=field, blocker_type=blocker_type, status=status, age=age, search=search)
|
||||
summary = dashboard_summary(session)
|
||||
affected_clients = len({item["client"] for item in result["items"] if item.get("client")})
|
||||
affected_products = len({item["product_code"] for item in result["items"] if item.get("product_code")})
|
||||
return render(request, "blockers.html", {"result": result, "summary": summary, "affected_clients": affected_clients, "affected_products": affected_products, "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "client_filter": client or "", "installation_filter": installation or "", "field_filter": field or "", "blocker_type_filter": blocker_type or "", "status_filter": status or "OPEN", "age_filter": age or "", "search": search or "", "query_base": query_base(client=client, installation=installation, field=field, blocker_type=blocker_type, status=status, age=age, search=search)})
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
|
||||
@@ -154,10 +168,10 @@ def router(session_factory):
|
||||
session.close()
|
||||
|
||||
@routes.get("/jobs", response_class=HTMLResponse)
|
||||
def jobs(request: Request, page: int = 1, status: str | None = None):
|
||||
def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "jobs.html", {"result": list_jobs(session, page=page, page_size=50, status=status), "auth": auth_data})
|
||||
try: return render(request, "jobs.html", {"result": list_jobs(session, page=page, page_size=50, status=status, search=search, kind=kind), "auth": auth_data, "status_filter": status or "", "search": search or "", "kind_filter": kind or "", "query_base": query_base(status=status, search=search, kind=kind)})
|
||||
finally: session.close()
|
||||
|
||||
return routes
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
|
||||
document.addEventListener("click", event => {
|
||||
const action = event.target.closest("[data-confirm]");
|
||||
if (action && !window.confirm(action.dataset.confirm)) event.preventDefault();
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.documentElement.classList.add("js");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ detail.display_name }} | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="detail-head"><div><p class="eyebrow">{{ detail.client }} / INSTALAÇÃO</p><h1>{{ detail.display_name or detail.hostname or detail.installation_id }}</h1><p class="muted">{{ detail.hostname or "hostname indisponível" }} · Agent {{ detail.agent_id }} · Installation {{ detail.installation_id }}</p></div><span class="badge {{ detail.health|lower }} large">{{ detail.health }}</span></div>
|
||||
<section class="cards compact"><article class="metric"><span>Produtos</span><strong>{{ detail.total_products }}</strong></article><article class="metric"><span>Completos</span><strong>{{ detail.fiscally_complete }}</strong></article><article class="metric"><span>Safe blocked</span><strong>{{ detail.safe_blocked }}</strong></article><article class="metric"><span>Coverage</span><strong>{{ "%.1f"|format(detail.coverage_percent) }}%</strong></article></section>
|
||||
<nav class="tabs">{% for name in ["overview","fiscal","blockers","jobs","events"] %}<a class="{% if tab==name %}active{% endif %}" href="?tab={{ name }}">{{ name }}</a>{% endfor %}</nav>
|
||||
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2><form method="post" action="/agents/{{ detail.agent_id }}/reprocess" data-confirm="Disparar REPROCESS_ALL para esta instalação?"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="confirm" value="yes"><button class="secondary">Reprocessar tudo</button></form></div><dl class="facts"><dt>Empresa</dt><dd>{{ detail.company_name or "-" }}</dd><dt>UF / CRT</dt><dd>{{ detail.company_uf or "-" }} / {{ detail.company_crt or "-" }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}</dd><dt>Último heartbeat</dt><dd>{{ detail.last_seen_at or "-" }}</dd><dt>Último job</dt><dd>{{ detail.last_job_status or "-" }} · {{ detail.last_job_at or "-" }}</dd></dl></article><article class="panel"><h2>Eventos recentes</h2>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_at }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}</small></span><em class="severity {{ event.severity|lower }}">{{ event.severity }}</em></div>{% else %}<p class="muted">Nenhum evento.</p>{% endfor %}</article></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers">Ver todos</a></div>{% for item in blockers["items"][:8] %}<div class="list-row"><span><b>{{ item.product_code }}</b> · {{ item.field }}<small>{{ item.reason }}</small></span><em class="badge blocked">{{ item.blocker_type }}</em></div>{% else %}<p class="muted">Nenhum blocker aberto.</p>{% endfor %}</section>
|
||||
<div class="detail-head"><div><p class="eyebrow">{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO</p><h1>{{ detail.display_name or detail.hostname or detail.installation_id }}</h1><p class="muted">{{ detail.hostname or "hostname indisponível" }}</p></div><div class="badge-stack"><span class="badge {{ detail.technical_health|lower }} large">Saúde técnica: {{ detail.technical_health_label }}</span><span class="badge fiscal-{{ detail.fiscal_status|lower }} large">Status fiscal: {{ detail.fiscal_status_label }}</span></div></div>
|
||||
<section class="cards compact"><article class="metric"><span>Produtos</span><strong>{{ detail.total_products }}</strong></article><article class="metric"><span>Completos</span><strong>{{ detail.fiscally_complete }}</strong></article><article class="metric"><span>Bloqueados com segurança</span><strong>{{ detail.safe_blocked }}</strong></article><article class="metric"><span>Cobertura fiscal</span><strong>{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ detail.fiscally_complete }} / {{ detail.total_products }}</small></article></section>
|
||||
<nav class="tabs" aria-label="Seções da instalação">{% for name,label in [("overview","Resumo"),("fiscal","Fiscal"),("blockers","Blockers"),("jobs","Jobs"),("events","Eventos")] %}<a class="{% if tab==name %}active{% endif %}" href="?tab={{ name }}">{{ label }}</a>{% endfor %}</nav>
|
||||
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2><form method="post" action="/agents/{{ detail.agent_id }}/reprocess"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="confirm" value="yes"><button class="secondary" type="submit">Reprocessar tudo</button></form></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "-" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "-" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "-" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "-" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "-" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version or "Desconhecida" }} · {{ detail.version_drift|title }}</dd><dt>Último heartbeat</dt><dd title="{{ detail.last_seen_display }}">{{ detail.last_seen_display }} · {{ detail.last_seen_age }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}</dd><dt>IDs técnicos</dt><dd>Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}</dd></dl></article>
|
||||
<article class="panel"><div class="panel-title"><h2>Eventos recentes</h2><a href="#eventos">Ver eventos</a></div>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_display }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.job_id %} · Job {{ event.job_id }}{% endif %}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}</small></span><em class="severity {{ event.severity|lower }}">Severidade: {{ event.severity }}</em></div>{% else %}<p class="empty-state">Nenhum evento.</p>{% endfor %}</article></section>
|
||||
<section class="panel" id="eventos"><div class="panel-title"><div><h2>Eventos operacionais</h2><p class="muted">Filtros aplicados no servidor.</p></div></div><form class="filters compact-filters" method="get"><input type="hidden" name="tab" value="events"><label>Severidade<select name="severity"><option value="">Todas</option>{% for value in ["INFO","WARNING","ERROR","CRITICAL"] %}<option value="{{ value }}" {% if severity==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de evento<input name="event_type" value="{{ event_type }}"></label><label>Job ID<input name="job_id" value="{{ job_id }}" inputmode="numeric"></label><label>Produto<input name="product_code" value="{{ product_code }}"></label><button class="secondary" type="submit">Filtrar eventos</button></form><nav class="pagination" aria-label="Paginação de eventos">{% if event_page > 1 %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page - 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ event_page }}</span><a href="/agents/{{ detail.agent_id }}?event_page={{ event_page + 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Próxima</a></nav></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers">Ver todos</a></div>{% for item in blockers["items"][:8] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.product_code }}</b> · {{ item.field }}<small>{{ item.reason }} · {{ item.age }}</small></span><em class="badge blocked">{{ item.blocker_type }}</em></a>{% else %}<p class="empty-state">Nenhum blocker aberto.</p>{% endfor %}</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
{% extends "base.html" %}{% block title %}Agents | GTI Fiscal{% endblock %}{% block content %}<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1></div><form class="filters"><input name="search" value="{{ search }}" placeholder="Cliente, instalação ou hostname"><select name="health"><option value="">Todos os health</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option {% if health_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select><button class="secondary">Filtrar</button></form></div><div class="panel table-wrap"><table><thead><tr><th>Cliente / instalação</th><th>Servidor</th><th>Empresa</th><th>Health</th><th>Versão</th><th>Produtos</th><th>Complete</th><th>Blocked</th><th>Last seen</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td><a href="/agents/{{ item.agent_id }}"><b>{{ item.client }}</b><small>{{ item.display_name }}</small></a></td><td>{{ item.hostname or "-" }}</td><td>{{ item.company_name or item.company_code or "-" }}<small>{{ item.company_uf or "" }} {{ item.company_crt or "" }}</small></td><td><span class="badge {{ item.health|lower }}">{{ item.health }}</span></td><td>{{ item.agent_version or "UNKNOWN" }}<small>{{ item.version_drift }}</small></td><td>{{ item.total_products }}</td><td>{{ item.fiscally_complete }}</td><td>{{ item.safe_blocked }}</td><td>{{ item.last_seen_at or "Never" }}</td></tr>{% else %}<tr><td colspan="9" class="muted">Nenhuma instalação encontrada.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Agents | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
|
||||
<div class="panel table-wrap"><table><caption>Instalações monitoradas</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Saúde técnica</th><th>Status fiscal</th><th>Cobertura fiscal</th><th>Produtos</th><th>Versão</th><th>Último heartbeat</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b></a></td><td>{{ item.display_name or "-" }}<small>ID técnico: {{ item.installation_id }}</small></td><td>{{ item.hostname or "-" }}</td><td><span class="badge {{ item.technical_health|lower }}">Saúde técnica: {{ item.technical_health_label }}</span></td><td><span class="badge fiscal-{{ item.fiscal_status|lower }}">Status fiscal: {{ item.fiscal_status_label }}</span>{% if item.safe_blocked %}<small>{{ item.safe_blocked }} bloqueados com segurança</small>{% endif %}</td><td><strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ item.fiscally_complete }} / {{ item.total_products }}</small></td><td>{{ item.total_products }}<small>{{ item.failed_products }} falhas</small></td><td>{{ item.agent_version or "Desconhecida" }}<small>{{ item.version_drift|title }}</small></td><td title="{{ item.last_seen_display }}">{{ item.last_seen_display }}<small>{{ item.last_seen_age }}</small></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhuma instalação encontrada.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{% block title %}GTI Fiscal Control Panel{% endblock %}</title><link rel="stylesheet" href="/static/control-panel.css"></head>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{% block title %}GTI Fiscal Control Panel{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/control-panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar"><a class="brand" href="/">GTI <span>FISCAL</span></a><nav><a href="/">Resumo</a><a href="/agents">Agents</a><a href="/blockers">Blockers</a><a href="/jobs">Jobs</a></nav>{% if auth %}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Sair</button></form>{% endif %}</header>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">GTI <span>FISCAL</span></a>
|
||||
<nav aria-label="Navegação principal"><a href="/">Resumo</a><a href="/agents">Agents</a><a href="/blockers">Blockers</a><a href="/jobs">Jobs</a></nav>
|
||||
{% if auth %}<form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost" type="submit">Sair</button></form>{% endif %}
|
||||
</header>
|
||||
<main class="shell">{% block content %}{% endblock %}</main>
|
||||
<script src="/static/control-panel.js"></script>
|
||||
</body></html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="detail-head"><div><p class="eyebrow">RESOLUÇÃO OPERACIONAL</p><h1>Blocker {{ detail.id }}</h1><p class="muted">{{ detail.client or detail.tenant_id }} · {{ detail.product_code }}</p></div><span class="badge blocked large">{{ detail.blocker_type }}</span></div>
|
||||
<div class="detail-head"><div><p class="eyebrow">RESOLUÇÃO OPERACIONAL</p><h1>Blocker {{ detail.id }}</h1><p class="muted">{{ detail.client or "Cliente não identificado" }} · {{ detail.product_code }}</p></div><div class="badge-stack"><span class="badge blocked large">Fiscal: {{ detail.blocker_type }}</span><span class="badge priority-{{ detail.priority|lower }} large">Prioridade: {{ detail.priority }}</span></div></div>
|
||||
{% if error %}<div class="panel"><p class="error">{{ error }}</p></div>{% endif %}
|
||||
<section class="split">
|
||||
<article class="panel"><h2>Detalhes do blocker</h2><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Servidor</dt><dd>{{ detail.server.hostname or "-" }}</dd><dt>Produto</dt><dd>{{ detail.product_code }}</dd><dt>Descrição</dt><dd>{{ detail.description or "-" }}</dd><dt>Campo</dt><dd>{{ detail.field or "-" }}</dd><dt>Tipo</dt><dd>{{ detail.blocker_type }}</dd><dt>Motivo</dt><dd>{{ detail.reason }}</dd><dt>Evidência</dt><dd>{{ detail.evidence }}</dd></dl></article>
|
||||
<article class="panel"><h2>Detalhes do blocker</h2><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Instalação</dt><dd>{{ detail.installation.display_name or "-" }}</dd><dt>Servidor</dt><dd>{{ detail.server.hostname or "-" }}</dd><dt>Produto</dt><dd>{{ detail.product_code }}</dd><dt>Descrição</dt><dd>{{ detail.description or "-" }}</dd><dt>Campo</dt><dd>{{ detail.field or "OTHER" }}</dd><dt>Tipo</dt><dd>{{ detail.blocker_type }}</dd><dt>Motivo</dt><dd>{{ detail.reason }}</dd><dt>Idade</dt><dd>{{ detail.age }}</dd><dt>Última ocorrência</dt><dd title="{{ detail.last_seen_display }}">{{ detail.last_seen_display }}</dd><dt>Evidência</dt><dd><pre class="evidence">{{ detail.evidence|tojson(indent=2) }}</pre></dd></dl></article>
|
||||
<article class="panel"><h2>Contexto atual</h2><p class="muted">Valor atual por chave persistida para esta empresa.</p>{% for entry in context_entries %}<div class="list-row"><span><b>{{ entry.key }}</b><small>{{ entry.scope }} / {{ entry.scope_key or "empresa" }}</small></span><em>{{ entry.value }}</em></div>{% else %}<p class="muted">Nenhum valor definido.</p>{% endfor %}</article>
|
||||
</section>
|
||||
<section class="panel"><h2>Salvar contexto e reprocessar</h2><p class="muted">Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.</p><form method="post" action="/blockers/{{ detail.id }}/resolve-context"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="form-grid"><label>Chave de contexto<select name="key" required>{% for key in context_keys %}<option value="{{ key }}">{{ key }}</option>{% endfor %}</select></label><label>Valor<input name="value" required maxlength="512"><small>Valores aceitos: {{ value_choices|join(", ") }}</small></label><label>Escopo<select name="scope" required><option value="PRODUCT">PRODUCT</option><option value="GROUP">GROUP</option><option value="COMPANY">COMPANY</option></select></label><label>Chave do escopo<input name="scope_key" value="{{ detail.product_code }}" required></label><label class="wide">Nota<textarea name="reason" required maxlength="2000"></textarea></label></div><div class="panel-title"><a href="/blockers">Cancelar</a><button type="submit">Salvar contexto e reprocessar</button></div></form></section>
|
||||
<section class="panel"><h2>Salvar contexto e reprocessar</h2><p class="muted">Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.</p><form method="post" action="/blockers/{{ detail.id }}/resolve-context"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="form-grid"><label for="context-key">Chave de contexto<select id="context-key" name="key" required>{% for key in context_keys %}<option value="{{ key }}">{{ key }}</option>{% endfor %}</select></label><label for="context-value">Valor<input id="context-value" name="value" required maxlength="512"><small>Valores aceitos: {{ value_choices|join(", ") }}</small></label><label for="context-scope">Escopo<select id="context-scope" name="scope" required><option value="PRODUCT">PRODUCT</option><option value="GROUP">GROUP</option><option value="COMPANY">COMPANY</option></select></label><label for="scope-key">Chave do escopo<input id="scope-key" name="scope_key" value="{{ detail.product_code }}" required></label><label class="wide" for="context-reason">Nota<textarea id="context-reason" name="reason" required maxlength="2000"></textarea></label></div><div class="panel-title"><a href="/blockers">Cancelar</a><button type="submit">Salvar contexto e reprocessar</button></div></form></section>
|
||||
<section class="split"><article class="panel"><h2>Eventos relacionados</h2>{% for event in detail.related_events[:10] %}<div class="event-row"><span>{{ event.severity }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}</small></span></div>{% else %}<p class="empty-state">Nenhum evento relacionado.</p>{% endfor %}</article><article class="panel"><h2>Jobs relacionados</h2>{% for job in detail.related_jobs[:10] %}<div class="list-row"><span><b>Job {{ job.job_id }}</b><small>{{ job.kind }} · {{ job.status }}</small></span><em>{{ job.duration }}</em></div>{% else %}<p class="empty-state">Nenhum job relacionado.</p>{% endfor %}</article></section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers globais</h1></div></div><div class="panel table-wrap"><table><thead><tr><th>Cliente</th><th>Produto</th><th>Campo</th><th>Tipo</th><th>Motivo</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or item.tenant_id }}</td><td><b>{{ item.product_code }}</b><small>{{ item.description or "" }}</small></td><td>{{ item.field }}</td><td><span class="badge blocked">{{ item.blocker_type }}</span></td><td>{{ item.reason }}</td><td>{{ item.last_seen_at }}<small>{{ item.retry_count }} retries</small></td><td><a class="secondary" href="/blockers/{{ item.id }}">Resolver com contexto</a></td></tr>{% else %}<tr><td colspan="7" class="muted">Nenhum blocker aberto.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blockers | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers globais</h1><p class="muted">Problemas fiscais separados de falhas técnicas.</p></div></div>
|
||||
<section class="cards compact"><article class="metric"><span>Blockers abertos</span><strong>{{ summary.open_blockers }}</strong></article><article class="metric"><span>Produtos afetados</span><strong>{{ summary.safe_blocked_products }}</strong></article><article class="metric"><span>Clientes afetados</span><strong>{{ affected_clients }}</strong></article><article class="metric"><span>Resultado filtrado</span><strong>{{ result.total }}</strong></article></section>
|
||||
<section class="panel"><form class="filters blocker-filters" method="get"><label>Cliente<input name="client" value="{{ client_filter }}"></label><label>Instalação / Agent / Servidor<input name="installation" value="{{ installation_filter }}"></label><label>Campo<select name="field"><option value="">Todos</option>{% for value in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}"></label><label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label><label>Idade<input name="age" value="{{ age_filter }}" placeholder="ex.: 1d"></label><label>Produto ou busca<input name="search" value="{{ search }}"></label><button class="secondary" type="submit">Filtrar blockers</button></form></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers abertos</h2><span class="muted">CFOP {{ summary.open_blocker_breakdown.CFOP }} · ST {{ summary.open_blocker_breakdown.ST }} · ICMS {{ summary.open_blocker_breakdown.ICMS }} · CSOSN {{ summary.open_blocker_breakdown.CSOSN }} · NCM {{ summary.open_blocker_breakdown.NCM }} · OTHER {{ summary.open_blocker_breakdown.OTHER }}</span></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b></div>{% endfor %}</div></section>
|
||||
<div class="panel table-wrap"><table><caption>Fila de blockers</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Produto</th><th>Campo</th><th>Tipo de blocker</th><th>Prioridade</th><th>Problema</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or "Cliente não identificado" }}</td><td>{{ item.display_name or "-" }}<small>{{ item.hostname or "hostname indisponível" }}</small></td><td><b>{{ item.product_code }}</b><small>{{ item.description or "" }}</small></td><td>{{ item.field or "OTHER" }}</td><td><span class="badge blocked">Fiscal: {{ item.blocker_type }}</span></td><td><span class="badge priority-{{ item.priority|lower }}">Prioridade: {{ item.priority }}</span></td><td>{{ item.reason }}<small>{{ item.age }}</small></td><td title="{{ item.last_seen_display }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td><a class="secondary" href="{{ item.link }}">Ver detalhe</a></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhum blocker corresponde aos filtros.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de blockers">{% if result.page > 1 %}<a href="/blockers?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} blockers</span>{% if result.page * result.page_size < result.total %}<a href="/blockers?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
{% extends "base.html" %}{% block title %}Resumo | GTI Fiscal{% endblock %}{% block content %}<div class="page-heading"><div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1></div><span class="live-dot">● Atualizado pelo snapshot</span></div><section class="cards">{% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("safe_blocked","Safe blocked")] %}<article class="metric"><span>{{ label }}</span><strong>{{ summary[key] }}</strong></article>{% endfor %}</section><section class="split"><article class="panel"><div class="panel-title"><h2>Cobertura fiscal</h2><a href="/agents">Ver instalações</a></div><div class="coverage"><div><strong>{{ summary.total_products }}</strong><span>Produtos</span></div><div><strong>{{ summary.fiscally_complete }}</strong><span>Completos</span></div><div><strong>{{ summary.failed_products }}</strong><span>Falhas técnicas</span></div></div></article><article class="panel"><div class="panel-title"><h2>Principais blockers</h2><a href="/blockers">Abrir fila</a></div>{% for key,value in summary.blockers.items() %}<div class="bar-row"><span>{{ key }}</span><b>{{ value }}</b></div>{% else %}<p class="muted">Nenhum blocker aberto.</p>{% endfor %}</article></section>{% if summary.stale_agents %}<section class="panel"><div class="panel-title"><h2>Heartbeat atrasado</h2><a href="/agents?health=OFFLINE">Filtrar Agents</a></div>{% for item in summary.stale_agents[:8] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client }}</b> · {{ item.display_name }}<small>{{ item.hostname or "hostname indisponível" }}</small></span><em class="badge offline">OFFLINE</em></a>{% endfor %}</section>{% endif %}{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Resumo | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1><p class="muted">Fila operacional baseada no último snapshot recebido.</p></div><span class="live-dot">Atualizado pelo snapshot</span></div>
|
||||
<section class="cards" aria-label="Saúde dos agents">
|
||||
{% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("unknown","Unknown")] %}<article class="metric"><span>{{ label }}</span><strong>{{ summary[key] }}</strong></article>{% endfor %}
|
||||
</section>
|
||||
<section class="split">
|
||||
<article class="panel"><div class="panel-title"><h2>Métricas fiscais</h2><a href="/agents">Ver instalações</a></div><div class="coverage"><div><strong>{{ summary.safe_blocked_products }}</strong><span>Produtos bloqueados</span></div><div><strong>{{ summary.open_blockers }}</strong><span>Blockers abertos</span></div><div><strong>{{ summary.total_products }}</strong><span>Produtos avaliados</span></div><div><strong>{{ summary.fiscally_complete }}</strong><span>Completos</span></div></div></article>
|
||||
<article class="panel"><div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers">Abrir fila</a></div>{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b></div>{% endfor %}</article>
|
||||
</section>
|
||||
<section class="panel attention"><div class="panel-title"><div><p class="eyebrow">FILA DE AÇÃO</p><h2>ATENÇÃO NECESSÁRIA</h2></div><a href="/blockers">Ver todos</a></div>{% for item in summary.attention_items[:10] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.client or "Cliente não identificado" }}</b> · {{ item.display_name or "Instalação" }}<small>{{ item.hostname or "hostname indisponível" }} · {{ item.problem }}</small></span><em class="badge {{ item.technical_health|lower }}">{{ item.age }}</em></a>{% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}</section>
|
||||
<section class="split">
|
||||
<article class="panel"><div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?status=FAILED">Ver jobs</a></div>{% if summary.failed_jobs %}<p class="metric-inline"><strong>{{ summary.failed_jobs }}</strong> jobs aguardam investigação.</p>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}</article>
|
||||
<article class="panel"><div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?sort=worst_coverage">Ver agents</a></div>{% for item in summary.outdated_agents[:10] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client }}</b> · {{ item.display_name }}<small>{{ item.hostname or "hostname indisponível" }} · {{ item.agent_version or "versão desconhecida" }}</small></span><em class="badge warning">Desatualizado</em></a>{% else %}<p class="empty-state">Nenhum agent desatualizado.</p>{% endfor %}</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
{% extends "base.html" %}{% block title %}Jobs | GTI Fiscal{% endblock %}{% block content %}<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs globais</h1></div></div><div class="panel table-wrap"><table><thead><tr><th>Job</th><th>Agent</th><th>Kind</th><th>Status</th><th>Created</th><th>Finished</th><th>Error</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>#{{ item.job_id }}</td><td>{{ item.agent_id }}</td><td>{{ item.kind }}</td><td><span class="badge {{ item.status|lower }}">{{ item.status }}</span></td><td>{{ item.created_at }}</td><td>{{ item.finished_at or "-" }}</td><td class="error-text">{{ item.error or "-" }}</td></tr>{% else %}<tr><td colspan="7" class="muted">Nenhum job encontrado.</td></tr>{% endfor %}</tbody></table></div>{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jobs | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs globais</h1><p class="muted">Acompanhe o que está em andamento e onde falhou.</p></div></div>
|
||||
<section class="panel"><form class="filters" method="get"><label>Status<select name="status"><option value="">Todos</option>{% for value in ["PENDING","RUNNING","COMPLETED","FAILED"] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Cliente, instalação ou busca<input name="search" value="{{ search }}"></label><label>Tipo<input name="kind" value="{{ kind_filter }}"></label><button class="secondary" type="submit">Filtrar jobs</button></form></section>
|
||||
<div class="panel table-wrap"><table><caption>Execuções recentes</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Job ID</th><th>Tipo</th><th>Status</th><th>Criado</th><th>Finalizado</th><th>Duração</th><th>Erro</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or "Cliente não identificado" }}</td><td>{{ item.display_name or "-" }}</td><td>{{ item.hostname or "-" }}</td><td>#{{ item.job_id }}<small>Agent técnico {{ item.agent_id }}</small></td><td>{{ item.kind }}</td><td><span class="badge status-{{ item.status|lower }}">Status: {{ item.status }}</span></td><td title="{{ item.created_display }}">{{ item.created_display }}</td><td title="{{ item.finished_display }}">{{ item.finished_display }}</td><td>{{ item.duration }}</td><td class="error-text">{{ item.error or "-" }}</td></tr>{% else %}<tr><td colspan="10" class="empty-state">Nenhum job encontrado para os filtros atuais.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de jobs">{% if result.page > 1 %}<a href="/jobs?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} jobs</span>{% if result.page * result.page_size < result.total %}<a href="/jobs?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app.blocked_products import upsert_blocker
|
||||
from central.app.events import record_event
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentSnapshot, Installation, Job
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
|
||||
def _panel_client():
|
||||
session_factory = setup_db()
|
||||
agent_id, _ = seed(session_factory)
|
||||
now = datetime(2026, 9, 22, 19, 11, tzinfo=timezone.utc)
|
||||
with session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
agent = session.get(Agent, agent_id)
|
||||
agent.agent_version = "0.9.0"
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
snapshot.total_products = 50
|
||||
snapshot.fiscally_complete = 49
|
||||
snapshot.coverage_percent = 98
|
||||
job = Job(
|
||||
agent_id=agent_id,
|
||||
kind="REPROCESS_PRODUCTS",
|
||||
status="RUNNING",
|
||||
created_at=now - timedelta(minutes=3),
|
||||
started_at=now - timedelta(minutes=2),
|
||||
payload={"codigos": ["P-001"]},
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
blocker = upsert_blocker(
|
||||
session,
|
||||
installation.tenant_id,
|
||||
{
|
||||
"company": "Acme",
|
||||
"product_code": "P-001",
|
||||
"description": "Produto de teste",
|
||||
"blocker_type": "BLOCKED_MISSING_EVIDENCE",
|
||||
"field": "CFOP",
|
||||
"reason_code": "MISSING_CONTEXT",
|
||||
"input_fingerprint": "page-test",
|
||||
"evidence_summary": {"source": "snapshot"},
|
||||
},
|
||||
installation_id=installation.id,
|
||||
)
|
||||
record_event(
|
||||
session,
|
||||
tenant_id=installation.tenant_id,
|
||||
installation_id=installation.id,
|
||||
agent_id=agent_id,
|
||||
severity="WARNING",
|
||||
event_type="PRODUCT_BLOCKED",
|
||||
message="Contexto fiscal ausente",
|
||||
job_id=job.id,
|
||||
product_code="P-001",
|
||||
blocker_id=blocker.id,
|
||||
occurred_at=now,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
|
||||
login = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert login.status_code == 303
|
||||
return client, agent_id, blocker.id
|
||||
|
||||
|
||||
def test_dashboard_renders_operational_queue_labels_and_human_dates():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Agents total", "Online", "Degraded", "Offline", "Failed", "Unknown"):
|
||||
assert label in response.text
|
||||
assert "Produtos bloqueados" in response.text
|
||||
assert "Blockers abertos" in response.text
|
||||
assert "ATENÇÃO NECESSÁRIA" in response.text
|
||||
assert "Ver todos" in response.text
|
||||
assert "Jobs com falha" in response.text
|
||||
assert "Agents desatualizados" in response.text
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", response.text)
|
||||
|
||||
|
||||
def test_agents_render_separate_health_and_fiscal_status_with_server_sort():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/agents?sort=worst_coverage&health=ONLINE&search=Acme")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente", "Nome de exibição", "Hostname", "Saúde técnica", "Status fiscal", "Cobertura fiscal", "Versão"):
|
||||
assert label in response.text
|
||||
assert "Acme ERP" in response.text
|
||||
assert "srv-01" in response.text
|
||||
assert "98,0%" in response.text
|
||||
assert 'name="health"' in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert 'value="worst_coverage"' in response.text
|
||||
|
||||
|
||||
def test_jobs_render_client_identity_duration_and_preserve_filters():
|
||||
client, _, _ = _panel_client()
|
||||
|
||||
response = client.get("/jobs?status=RUNNING&search=Acme")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente", "Nome de exibição", "Hostname", "Job ID", "Tipo", "Status", "Criado", "Finalizado", "Duração", "Erro"):
|
||||
assert label in response.text
|
||||
assert "Acme" in response.text
|
||||
assert "srv-01" in response.text
|
||||
assert "em andamento" in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert '<option value="RUNNING" selected>' in response.text
|
||||
assert "Agent 1" not in response.text
|
||||
|
||||
|
||||
def test_blockers_render_visible_filters_summary_priority_and_detail_link():
|
||||
client, _, blocker_id = _panel_client()
|
||||
|
||||
response = client.get("/blockers?client=Acme&installation=srv-01&field=CFOP&blocker_type=BLOCKED_MISSING_EVIDENCE&status=OPEN&age=0h&search=P-001")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente", "Instalação / Agent / Servidor", "Campo", "Tipo de blocker", "Status", "Idade", "Produto ou busca", "Blockers abertos", "Produtos afetados", "Clientes afetados", "CFOP", "ST", "ICMS", "CSOSN", "NCM", "OTHER", "Prioridade"):
|
||||
assert label in response.text
|
||||
assert f"/blockers/{blocker_id}" in response.text
|
||||
assert "CRITICAL" in response.text or "HIGH" in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert 'value="BLOCKED_MISSING_EVIDENCE"' in response.text
|
||||
assert "window.confirm" not in response.text
|
||||
|
||||
|
||||
def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows():
|
||||
client, agent_id, blocker_id = _panel_client()
|
||||
|
||||
agent_response = client.get(f"/agents/{agent_id}?tab=events&severity=WARNING&event_type=PRODUCT_BLOCKED&page=2")
|
||||
blocker_response = client.get(f"/blockers/{blocker_id}")
|
||||
|
||||
assert agent_response.status_code == 200
|
||||
for label in ("Cliente", "Nome de exibição", "Hostname", "Saúde técnica", "Status fiscal", "Último heartbeat", "Versão", "Empresa", "UF", "CRT", "Eventos recentes", "Severidade", "Tipo de evento"):
|
||||
assert label in agent_response.text
|
||||
assert "Contexto fiscal ausente" in agent_response.text
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", agent_response.text)
|
||||
assert "severity=WARNING" in agent_response.text
|
||||
assert "event_type=PRODUCT_BLOCKED" in agent_response.text
|
||||
|
||||
assert blocker_response.status_code == 200
|
||||
for label in ("Detalhes do blocker", "Evidência", "Contexto atual", "Salvar contexto e reprocessar", "Chave de contexto", "Valor", "Escopo", "Nota"):
|
||||
assert label in blocker_response.text
|
||||
assert 'name="context"' not in blocker_response.text
|
||||
assert "data-confirm" not in blocker_response.text
|
||||
assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", blocker_response.text)
|
||||
Reference in New Issue
Block a user