diff --git a/central/app/control_plane.py b/central/app/control_plane.py index a233811..ad2116d 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -117,13 +117,19 @@ def _attention_item(item, problem, timestamp, *, rank, link=None, now=None): def installation_view(installation, agent, tenant, snapshot=None, *, now=None): + has_snapshot = snapshot is not None snapshot = snapshot or AgentSnapshot(installation_id=installation.id) health, reason = compute_agent_health(installation, now=now, supplemental=snapshot) version_drift = snapshot.version_drift - if version_drift == "UNKNOWN": + if not version_drift or version_drift == "UNKNOWN": version_drift = compute_version_drift(agent.agent_version) fiscal_status = _fiscal_status(snapshot) coverage_partition = _coverage_partition(snapshot) + if not has_snapshot: + # No heartbeat/snapshot yet (e.g. an enrollment-only E2E row): expose + # no counts at all rather than fabricated zeros — the UI shows + # "Não informado" instead of None/0. + coverage_partition = {"unclassified_products": None, "coverage_overcount_products": 0, "coverage_invalid_counts": False, "coverage_invariant_pass": True} version_drift_label = {"CURRENT": "Atualizada", "OUTDATED": "Desatualizada", "UNKNOWN": "Desconhecida"}.get(version_drift, str(version_drift)) agent_version_display = f"{agent.agent_version} · {version_drift_label}" if agent.agent_version else "Desconhecida" return { @@ -153,15 +159,15 @@ def installation_view(installation, agent, tenant, snapshot=None, *, now=None): "last_seen_age": format_age(installation.last_seen_at, now), "started_at": _iso(installation.started_at), "last_status": installation.last_status, - "total_products": snapshot.total_products, - "fiscally_complete": snapshot.fiscally_complete, - "safe_blocked": snapshot.safe_blocked, - "failed_products": snapshot.failed_products, + "total_products": snapshot.total_products if has_snapshot else None, + "fiscally_complete": snapshot.fiscally_complete if has_snapshot else None, + "safe_blocked": snapshot.safe_blocked if has_snapshot else None, + "failed_products": snapshot.failed_products if has_snapshot else None, **coverage_partition, - "coverage_percent": snapshot.coverage_percent, - "fiscal_completion_percent": _fiscal_completion(snapshot), - "fiscal_status": fiscal_status, - "fiscal_status_label": fiscal_status.title(), + "coverage_percent": snapshot.coverage_percent if has_snapshot else None, + "fiscal_completion_percent": _fiscal_completion(snapshot) if has_snapshot else None, + "fiscal_status": fiscal_status if has_snapshot else "UNKNOWN", + "fiscal_status_label": fiscal_status.title() if has_snapshot else "Não informado", "blockers": snapshot.blockers or {}, "last_job_status": snapshot.last_job_status, "last_job_at": _iso(snapshot.last_job_at), diff --git a/central/app/web.py b/central/app/web.py index 0a67e01..cf2b66e 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -161,53 +161,10 @@ def router(session_factory): 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", event_page: int = 1, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: str | None = None, until: str | None = None): - session, auth_data, redirect = page_auth(request) - if redirect: return redirect - try: - row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first() - if row is None: return HTMLResponse("Agent not found", status_code=404) - installation, agent, tenant, snapshot = row - detail = installation_view(installation, agent, tenant, snapshot) - event_page = max(1, event_page) - events = list_panel_events(session, installation.id, page=event_page, page_size=25, include_more=True, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=date_filter(since), until=date_filter(until, end_of_day=True)) - has_more = len(events) > 25 - events = events[:25] - for event in events: - event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None) - agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25) - return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")}) - finally: session.close() - - @routes.get("/agents/{agent_id}/reprocess-confirmation", response_class=HTMLResponse) - def reprocess_confirmation(request: Request, agent_id: int): - session, auth_data, redirect = page_auth(request) - if redirect: return redirect - try: - if not session.scalar(select(Agent.id).where(Agent.id == agent_id)): - return HTMLResponse("Agent not found", status_code=404) - return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data}) - finally: session.close() - - @routes.post("/agents/{agent_id}/reprocess") - async def reprocess_agent(request: Request, agent_id: int): - session, auth_data, redirect = page_auth(request) - if redirect: return redirect - try: - values = await form_values(request); web_session, admin = auth_data - require_csrf(web_session, values.get("csrf_token")) - if values.get("confirm") != "yes": return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data, "error": "Confirme explicitamente o reprocessamento."}, 400) - agent = session.get(Agent, agent_id) - installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) - if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) - existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == "REPROCESS_ALL", Job.status == "PENDING")).all() if candidate.payload == {}), None) - job = existing or create_job(session, agent, "REPROCESS_ALL", {}) - record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="REPROCESS_ALL", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id}) - session.commit() - return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303) - finally: session.close() - + # --- Static /agents/* routes MUST be declared before /agents/{agent_id}: + # Starlette matches in registration order, so a dynamic route declared + # first would capture "/agents/new" and "/agents/releases" and fail the + # int conversion with a 422 (the production bug this ordering prevents). @routes.get("/agents/releases", response_class=HTMLResponse) def agent_releases(request: Request): session, auth_data, redirect = page_auth(request) @@ -274,6 +231,60 @@ def router(session_factory): return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303) finally: session.close() + @routes.get("/agents/{agent_id}", response_class=HTMLResponse) + def agent_detail(request: Request, agent_id: str, 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, since: str | None = None, until: str | None = None): + # agent_id is parsed here (not typed int in the path) so a non-numeric + # slug like /agents/abc gets a controlled HTML 404 instead of a raw + # 422 — and can never shadow the static routes above. + try: + agent_id = int(agent_id) + except ValueError: + return HTMLResponse("Agent não encontrado (identificador inválido)", status_code=404) + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first() + if row is None: return HTMLResponse("Agent not found", status_code=404) + installation, agent, tenant, snapshot = row + detail = installation_view(installation, agent, tenant, snapshot) + event_page = max(1, event_page) + events = list_panel_events(session, installation.id, page=event_page, page_size=25, include_more=True, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=date_filter(since), until=date_filter(until, end_of_day=True)) + has_more = len(events) > 25 + events = events[:25] + for event in events: + event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None) + agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25) + return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")}) + finally: session.close() + + @routes.get("/agents/{agent_id}/reprocess-confirmation", response_class=HTMLResponse) + def reprocess_confirmation(request: Request, agent_id: int): + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + if not session.scalar(select(Agent.id).where(Agent.id == agent_id)): + return HTMLResponse("Agent not found", status_code=404) + return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data}) + finally: session.close() + + @routes.post("/agents/{agent_id}/reprocess") + async def reprocess_agent(request: Request, agent_id: int): + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + values = await form_values(request); web_session, admin = auth_data + require_csrf(web_session, values.get("csrf_token")) + if values.get("confirm") != "yes": return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data, "error": "Confirme explicitamente o reprocessamento."}, 400) + agent = session.get(Agent, agent_id) + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) + existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == "REPROCESS_ALL", Job.status == "PENDING")).all() if candidate.payload == {}), None) + job = existing or create_job(session, agent, "REPROCESS_ALL", {}) + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="REPROCESS_ALL", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id}) + session.commit() + return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303) + finally: session.close() + def _pending_or_404(session, enrollment_uuid): return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid)) diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 516fb42..2101ff4 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -2,8 +2,8 @@ {% block title %}{{ detail.display_name }} | GTI Fiscal{% endblock %} {% block content %}

{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO

{{ detail.display_name or detail.hostname or detail.installation_id }}

{{ detail.hostname or "hostname indisponível" }}

Saúde técnica: {{ detail.technical_health_label }}Status fiscal: {{ detail.fiscal_status_label }}
-
Produtos{{ detail.total_products }}
Completos{{ detail.fiscally_complete }}
Bloqueados com segurança{{ detail.safe_blocked }}
Falhas técnicas{{ detail.failed_products }}
Não classificados{{ detail.unclassified_products }}
Cobertura fiscal{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%{{ detail.fiscally_complete }} / {{ detail.total_products }} completos
-{% if detail.coverage_invariant_pass %}

Cobertura reconciliada para este servidor.

{% else %}

Inconsistência de cobertura: {{ detail.coverage_overcount_products }} acima do total{% if detail.coverage_invalid_counts %}; existem contagens negativas{% endif %}.

{% endif %} +
Produtos{{ detail.total_products if detail.total_products is not none else "Não informado" }}
Completos{{ detail.fiscally_complete if detail.fiscally_complete is not none else "Não informado" }}
Bloqueados com segurança{{ detail.safe_blocked if detail.safe_blocked is not none else "Não informado" }}
Falhas técnicas{{ detail.failed_products if detail.failed_products is not none else "Não informado" }}
Não classificados{{ detail.unclassified_products if detail.unclassified_products is not none else "Não informado" }}
Cobertura fiscal{% if detail.fiscal_completion_percent is none %}Não informadonenhum snapshot recebido{% else %}{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%{{ detail.fiscally_complete }} / {{ detail.total_products }} completos{% endif %}
+{% if detail.total_products is none %}

Cobertura ainda não informada: nenhum snapshot recebido deste servidor.

{% elif detail.coverage_invariant_pass %}

Cobertura reconciliada para este servidor.

{% else %}

Inconsistência de cobertura: {{ detail.coverage_overcount_products }} acima do total{% if detail.coverage_invalid_counts %}; existem contagens negativas{% endif %}.

{% endif %}

Contexto da empresa

Reprocessar tudo
Cliente
{{ detail.client or "Não informado" }}
Nome de exibição
{{ detail.display_name or "Não informado" }}
Hostname
{{ detail.hostname or "Não informado" }}
Empresa
{{ detail.company_name or detail.company_code or "Não informado" }}
UF
{{ detail.company_uf or "Não informado" }}
CRT
{{ detail.company_crt or "Não informado" }}
Versão do agent
{{ detail.agent_version_display }}
Último heartbeat
{{ detail.last_seen_display }} · {{ detail.last_seen_age }}
Firebird
{{ detail.firebird_version or "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}
IDs técnicos
Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}

Eventos recentes

Ver eventos
{% for event in events[:8] %}
{{ event.occurred_display }}{{ event.event_type }}{{ event.message }}{% if event.job_id %} · Job {{ event.job_id }}{% endif %}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}Severidade: {{ event.severity }}
{% else %}

Nenhum evento.

{% endfor %}
diff --git a/central/templates/agents.html b/central/templates/agents.html index 9a7ee09..0d89968 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -3,6 +3,6 @@ {% block content %}

INVENTÁRIO OPERACIONAL

Agents / Servidores

Identidade do cliente primeiro; IDs técnicos ficam no detalhe.

+ Nova instalação Releases
-
{% for item in result["items"] %}{% else %}{% endfor %}
Instalações monitoradas
ClienteNome de exibiçãoHostnameSaúde técnicaStatus fiscalCobertura fiscalProdutosVersãoÚltimo heartbeat
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Não informado" }}{{ item.hostname or "Não informado" }}Saúde técnica: {{ item.technical_health_label }}Status fiscal: {{ item.fiscal_status_label }}{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados{% if not item.coverage_invariant_pass %}Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}{% endif %}{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%{{ item.fiscally_complete }} / {{ item.total_products }} completos{{ item.total_products }}{{ item.failed_products }} falhas técnicas{{ item.agent_version_display }}{{ item.last_seen_display }}{{ item.last_seen_age }}
Nenhuma instalação encontrada.
+
{% for item in result["items"] %}{% else %}{% endfor %}
Instalações monitoradas
ClienteNome de exibiçãoHostnameSaúde técnicaStatus fiscalCobertura fiscalProdutosVersãoÚltimo heartbeat
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Não informado" }}{{ item.hostname or "Não informado" }}Saúde técnica: {{ item.technical_health_label }}Status fiscal: {{ item.fiscal_status_label }}{% if item.total_products is none %}Cobertura ainda não informada (nenhum snapshot recebido){% else %}{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados{% if not item.coverage_invariant_pass %}Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}{% endif %}{% endif %}{% if item.fiscal_completion_percent is none %}–Não informado{% else %}{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%{{ item.fiscally_complete }} / {{ item.total_products }} completos{% endif %}{% if item.total_products is none %}Não informado{% else %}{{ item.total_products }}{{ item.failed_products }} falhas técnicas{% endif %}{{ item.agent_version_display }}{{ item.last_seen_display }}{{ item.last_seen_age }}
Nenhuma instalação encontrada.
{% endblock %} diff --git a/central/tests/test_agent_distribution.py b/central/tests/test_agent_distribution.py index 5b525e2..7a6bf5a 100644 --- a/central/tests/test_agent_distribution.py +++ b/central/tests/test_agent_distribution.py @@ -215,6 +215,49 @@ def test_direct_authenticated_download_does_not_need_a_link(hub): assert payload["installation_id"] == installation_uuid +# ---------- route precedence: /agents/new vs /agents/{agent_id} ---------- + +def test_get_agents_new_renders_form_not_captured_by_dynamic_route(hub): + client, factory = hub + publish_release(factory) + login(client) + response = client.get("/agents/new") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Nova instalação" in response.text + assert "Criar instalação" in response.text + + +def test_get_agents_releases_page_not_captured_by_dynamic_route(hub): + client, _factory = hub + login(client) + response = client.get("/agents/releases") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Versões do Agent" in response.text + + +def test_get_agent_detail_still_renders_for_valid_numeric_id(hub): + client, factory = hub + login(client) + with factory() as session: + agent_id = session.scalar(select(Agent.id)) + response = client.get(f"/agents/{agent_id}") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +def test_get_agents_non_numeric_slug_is_controlled_404_and_never_captures_statics(hub): + client, _factory = hub + login(client) + response = client.get("/agents/abc") + assert response.status_code == 404 + assert "text/html" in response.headers["content-type"] + # the legitimate static routes keep working regardless of the slug above + assert client.get("/agents/new").status_code == 200 + assert client.get("/agents/releases").status_code == 200 + + # ---------- download-link security ---------- def _pending(factory, release_id, **overrides): diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index 6429ff8..f936bcb 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -186,6 +186,27 @@ def test_agents_render_separate_health_and_fiscal_status_with_server_sort(): assert 'value="worst_coverage"' in response.text +def test_agent_list_and_detail_show_nao_informado_when_snapshot_missing(): + client, agent_id, _ = _panel_client() + with client._session_factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + session.query(AgentSnapshot).filter(AgentSnapshot.installation_id == installation.id).delete() + session.commit() + + listing = client.get("/agents") + assert listing.status_code == 200 + assert "None completos" not in listing.text + assert "None bloqueados" not in listing.text + assert ">None<" not in listing.text + assert "Não informado" in listing.text + + detail = client.get(f"/agents/{agent_id}") + assert detail.status_code == 200 + assert ">None<" not in detail.text + assert "None completos" not in detail.text + assert "Não informado" in detail.text + + def test_agent_list_hides_technical_uuid_and_explains_remainder(): client, agent_id, _ = _panel_client() with client._session_factory() as session: