From ce606b04cc776542b5cfc009288dd66f122402ca Mon Sep 17 00:00:00 2001 From: gti Date: Tue, 22 Sep 2026 23:04:53 -0300 Subject: [PATCH] fix: reconcile final control panel coverage UX --- central/app/control_plane.py | 36 +++++++- .../scripts/generate_control_panel_fixture.py | 7 +- central/static/control-panel.css | 13 ++- central/templates/agent_detail.html | 5 +- central/templates/agents.html | 2 +- central/templates/dashboard.html | 2 +- central/templates/login.html | 2 +- central/tests/test_control_panel_fixture.py | 2 + central/tests/test_control_panel_pages.py | 87 ++++++++++++++++++- central/tests/test_operational_view_models.py | 64 ++++++++++++++ docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md | 23 ++++- ...-control-panel-final-predeploy-fixes-v1.md | 81 +++++++++++++++++ 12 files changed, 312 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-22-control-panel-final-predeploy-fixes-v1.md diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 748321b..a233811 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -76,6 +76,24 @@ def _fiscal_status(snapshot) -> str: return "INCOMPLETE" +def _coverage_partition(snapshot) -> dict: + """Expose only explicit snapshot states and label the remaining count honestly.""" + total = int(getattr(snapshot, "total_products", 0) or 0) + complete = int(getattr(snapshot, "fiscally_complete", 0) or 0) + safe_blocked = int(getattr(snapshot, "safe_blocked", 0) or 0) + failed = int(getattr(snapshot, "failed_products", 0) or 0) + explicit = complete + safe_blocked + failed + residual = total - explicit + invalid_counts = min(total, complete, safe_blocked, failed) < 0 + overcount = max(0, explicit - total) + return { + "unclassified_products": max(0, residual) if not invalid_counts else 0, + "coverage_overcount_products": overcount, + "coverage_invalid_counts": invalid_counts, + "coverage_invariant_pass": not invalid_counts and overcount == 0, + } + + def _technical_timestamp(item): if item["technical_health"] in {"FAILED", "DEGRADED"}: return item.get("last_job_at") or item.get("last_seen_at") @@ -105,6 +123,9 @@ def installation_view(installation, agent, tenant, snapshot=None, *, now=None): if version_drift == "UNKNOWN": version_drift = compute_version_drift(agent.agent_version) fiscal_status = _fiscal_status(snapshot) + coverage_partition = _coverage_partition(snapshot) + 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 { "agent_id": agent.id, "installation_id": installation.installation_id, @@ -119,7 +140,9 @@ def installation_view(installation, agent, tenant, snapshot=None, *, now=None): "company_uf": installation.company_uf, "company_crt": installation.company_crt, "agent_version": agent.agent_version, + "agent_version_display": agent_version_display, "version_drift": version_drift, + "version_drift_label": version_drift_label, "health": health, "health_reason": reason, "technical_health": health, @@ -134,6 +157,7 @@ def installation_view(installation, agent, tenant, snapshot=None, *, now=None): "fiscally_complete": snapshot.fiscally_complete, "safe_blocked": snapshot.safe_blocked, "failed_products": snapshot.failed_products, + **coverage_partition, "coverage_percent": snapshot.coverage_percent, "fiscal_completion_percent": _fiscal_completion(snapshot), "fiscal_status": fiscal_status, @@ -362,15 +386,25 @@ def dashboard_summary(session, now=None): for state, count in health_rows: counts[state] = int(count) + snapshot_total = func.coalesce(AgentSnapshot.total_products, 0) + snapshot_complete = func.coalesce(AgentSnapshot.fiscally_complete, 0) + snapshot_blocked = func.coalesce(AgentSnapshot.safe_blocked, 0) + snapshot_failed = func.coalesce(AgentSnapshot.failed_products, 0) + snapshot_explicit = snapshot_complete + snapshot_blocked + snapshot_failed + snapshot_has_negative = or_(snapshot_total < 0, snapshot_complete < 0, snapshot_blocked < 0, snapshot_failed < 0) total_row = session.execute( select( func.coalesce(func.sum(AgentSnapshot.total_products), 0), func.coalesce(func.sum(AgentSnapshot.fiscally_complete), 0), func.coalesce(func.sum(AgentSnapshot.safe_blocked), 0), func.coalesce(func.sum(AgentSnapshot.failed_products), 0), + func.coalesce(func.sum(case((and_(~snapshot_has_negative, snapshot_explicit <= snapshot_total), snapshot_total - snapshot_explicit), else_=0)), 0), + func.coalesce(func.sum(case((and_(~snapshot_has_negative, snapshot_explicit > snapshot_total), snapshot_explicit - snapshot_total), else_=0)), 0), + func.coalesce(func.sum(case((snapshot_has_negative, 1), else_=0)), 0), ).select_from(Installation).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id) ).one() - totals = dict(zip(("total_products", "fiscally_complete", "safe_blocked", "failed_products"), (int(value or 0) for value in total_row))) + totals = dict(zip(("total_products", "fiscally_complete", "safe_blocked", "failed_products", "unclassified_products", "coverage_overcount_products", "coverage_invalid_snapshots"), (int(value or 0) for value in total_row))) + totals["coverage_invariant_pass"] = totals["coverage_overcount_products"] == 0 and totals["coverage_invalid_snapshots"] == 0 open_filter = BlockedProduct.resolved_at.is_(None) open_blockers = session.scalar(select(func.count(BlockedProduct.id)).where(open_filter)) or 0 diff --git a/central/scripts/generate_control_panel_fixture.py b/central/scripts/generate_control_panel_fixture.py index dd2106f..86535aa 100644 --- a/central/scripts/generate_control_panel_fixture.py +++ b/central/scripts/generate_control_panel_fixture.py @@ -23,7 +23,12 @@ def generate_fixture(session, agent_count=100, seed=7): last_seen = None if state == "UNKNOWN" else now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000) installation = Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")), display_name=f"{tenant.name} - Servidor Principal", customer_code=f"F{index:03d}", hostname=f"srv-{index:03d}", company_name=f"Empresa {index:03d}", company_uf="BA", company_crt="1", last_seen_at=last_seen, last_status="IDLE") session.add(installation); session.flush() - snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=1 if index % 2 == 0 else 0, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED") + total_products = 5000 + index + safe_blocked = 1 if index % 2 == 0 else 0 + failed_products = 1 if state == "FAILED" else 0 + fiscally_complete = total_products - safe_blocked - failed_products + coverage_percent = round(fiscally_complete * 100 / total_products, 1) + snapshot = AgentSnapshot(installation_id=installation.id, total_products=total_products, fiscally_complete=fiscally_complete, safe_blocked=safe_blocked, failed_products=failed_products, coverage_percent=coverage_percent, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED") session.add(snapshot); created["agents"] += 1 failed = state in {"DEGRADED", "FAILED"} diff --git a/central/static/control-panel.css b/central/static/control-panel.css index 35f4b00..3af7efb 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -43,10 +43,14 @@ small { display: block; font-size: 12px; } .panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; } .panel-title h2 { margin-bottom: 0; } .panel-title a { color: var(--accent); font-size: 12px; } -.coverage { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.panel-title a.secondary { color: #fff; font-size: inherit; } +.coverage { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; } .coverage div { border-left: 3px solid var(--accent-soft); padding-left: 12px; } .coverage strong { display: block; font-size: 23px; } .coverage span { color: var(--muted); font-size: 12px; } +.coverage-status { margin: 16px 0 0; color: var(--accent); font-size: 12px; font-weight: 700; } +.registry-summary { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; } +.registry-summary a { text-decoration: none; } .bar-row { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); padding: 8px 0; } .bar-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 24px; } .list-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit; } @@ -90,6 +94,12 @@ td a { text-decoration: none; } .event-row { display: grid; grid-template-columns: 120px 1fr auto; align-items: start; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--line); } .event-row:last-child { border-bottom: 0; } .event-time { color: var(--muted); font-size: 12px; } +.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--paper); } +.login-card { width: min(100%, 420px); display: grid; gap: 10px; padding: 30px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); } +.login-card .eyebrow, .login-card h1 { margin-bottom: 2px; } +.login-card label { margin-top: 6px; color: var(--muted); font-size: 13px; font-weight: 700; } +.login-card input { width: 100%; } +.login-card button { width: 100%; margin-top: 8px; } .form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; } .form-grid label { display: grid; gap: 5px; color: var(--muted); font-weight: 700; } .form-grid .wide { grid-column: 1 / -1; } @@ -120,4 +130,5 @@ td a { text-decoration: none; } .cards, .cards.compact, .split, .bar-grid { grid-template-columns: 1fr; } .filters label, .filters input, .filters select, .filters button { width: 100%; } .filters button { margin-top: 2px; } + .login-card { padding: 22px; } } diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 0312cb8..516fb42 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -2,9 +2,10 @@ {% 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 }}
Cobertura fiscal{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%{{ detail.fiscally_complete }} / {{ detail.total_products }}
+
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 %} -

Contexto da empresa

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

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 %}

Eventos operacionais

Filtros aplicados no servidor.

Blockers abertos

Ver todos
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ blocker_breakdown[key] }}
{% endfor %}
{% for item in blockers["items"][:8] %}{{ item.product_code }} · {{ item.field }}{{ item.reason }} · {{ item.age }}{{ item.blocker_type }}{% else %}

Nenhum blocker aberto.

{% endfor %}
diff --git a/central/templates/agents.html b/central/templates/agents.html index ff1c008..22a0260 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -2,6 +2,6 @@ {% block title %}Agents | GTI Fiscal{% endblock %} {% block content %}

INVENTÁRIO OPERACIONAL

Agents / Servidores

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

-
{% 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 "-" }}ID técnico: {{ item.installation_id }}{{ item.hostname or "-" }}Saúde técnica: {{ item.technical_health_label }}Status fiscal: {{ item.fiscal_status_label }}{% if item.safe_blocked %}{{ item.safe_blocked }} bloqueados com segurança{% endif %}{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%{{ item.fiscally_complete }} / {{ item.total_products }}{{ item.total_products }}{{ item.failed_products }} falhas{{ item.agent_version or "Desconhecida" }}{{ item.version_drift|title }}{{ 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 }}{{ 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.
{% endblock %} diff --git a/central/templates/dashboard.html b/central/templates/dashboard.html index 0e4db17..a815c50 100644 --- a/central/templates/dashboard.html +++ b/central/templates/dashboard.html @@ -6,7 +6,7 @@ {% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("unknown","Unknown")] %}
{{ label }}{{ summary[key] }}
{% endfor %}
-

Métricas fiscais

Ver instalações
{{ summary.safe_blocked_products }}Produtos bloqueados
{{ summary.open_blockers }}Blockers abertos
{{ summary.total_products }}Produtos avaliados
{{ summary.fiscally_complete }}Completos
+

Cobertura dos produtos

Ver instalações
{% for key,label in [("total_products","Produtos avaliados"),("fiscally_complete","Completos"),("safe_blocked","Bloqueados com segurança"),("failed_products","Falhas técnicas"),("unclassified_products","Não classificados") ] %}
{{ summary[key] }}{{ label }}
{% endfor %}
{% if summary.coverage_invariant_pass %}

Cobertura reconciliada: total = completos + bloqueados com segurança + falhas técnicas + não classificados.

{% else %}

Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.

{% endif %}
Produtos com blockers abertos: {{ summary.safe_blocked_products }}Blockers abertos: {{ summary.open_blockers }}

Breakdown dos blockers

Abrir fila
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ summary.open_blocker_breakdown[key] }}
{% endfor %}

FILA DE AÇÃO

ATENÇÃO NECESSÁRIA

Ver todos
{% for item in summary.attention_items[:10] %}{{ item.client or "Cliente não identificado" }} · {{ item.display_name or "Instalação" }}{{ item.hostname or "hostname indisponível" }} · {{ item.problem }}{{ item.age }}{% else %}

Nenhum item requer atenção imediata.

{% endfor %}
diff --git a/central/templates/login.html b/central/templates/login.html index 9f3cc0e..23545c5 100644 --- a/central/templates/login.html +++ b/central/templates/login.html @@ -1 +1 @@ -Login | GTI Fiscal

GTI FISCAL / CONTROL PANEL

Entrar no painel

{% if error %}
{{ error }}
{% endif %}
+Login | GTI Fiscal

GTI FISCAL / CONTROL PANEL

Entrar no painel

{% if error %}
{{ error }}
{% endif %}
diff --git a/central/tests/test_control_panel_fixture.py b/central/tests/test_control_panel_fixture.py index 1ab708d..83cd0d6 100644 --- a/central/tests/test_control_panel_fixture.py +++ b/central/tests/test_control_panel_fixture.py @@ -17,6 +17,8 @@ def test_fixture_creates_one_hundred_varied_installations_and_events(): assert {row.agent_health for row in snapshots} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN"} assert {row.version_drift for row in snapshots} == {"CURRENT", "OUTDATED", "UNKNOWN"} assert {row.installation_id for row in session.query(Installation).all()} == {str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")) for index in range(100)} + assert all(row.fiscally_complete + row.safe_blocked + row.failed_products == row.total_products for row in snapshots) + assert all(row.coverage_percent == round(row.fiscally_complete * 100 / row.total_products, 1) for row in snapshots) blockers = session.query(BlockedProduct).all() assert len({row.blocker_fingerprint for row in blockers}) == len(blockers) diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index 6ad3442..f8ba405 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -22,6 +22,8 @@ def _panel_client(): snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id)) snapshot.total_products = 50 snapshot.fiscally_complete = 49 + snapshot.safe_blocked = 1 + snapshot.failed_products = 0 snapshot.coverage_percent = 98 job = Job( agent_id=agent_id, @@ -80,7 +82,7 @@ def test_dashboard_renders_operational_queue_labels_and_human_dates(): 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 "Produtos com blockers abertos" in response.text assert "Blockers abertos" in response.text assert "ATENÇÃO NECESSÁRIA" in response.text assert "Ver todos" in response.text @@ -89,6 +91,30 @@ def test_dashboard_renders_operational_queue_labels_and_human_dates(): assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", response.text) +def test_dashboard_coverage_displays_reconciled_product_buckets(): + client, _, _ = _panel_client() + + response = client.get("/") + + assert response.status_code == 200 + for label in ("Produtos avaliados", "Completos", "Bloqueados com segurança", "Falhas técnicas", "Não classificados"): + assert label in response.text + assert "Produtos com blockers abertos" in response.text + assert "Cobertura reconciliada" in response.text + + +def test_login_has_centered_card_and_explicit_field_labels(): + client, _, _ = _panel_client() + + response = client.get("/login") + + assert response.status_code == 200 + assert 'class="login-page"' in response.text + assert 'class="login-card"' in response.text + assert '' in response.text + assert '' in response.text + + def test_dashboard_lists_bounded_failed_job_rows_with_identity_error_age_and_empty_state(): client, agent_id, _ = _panel_client() factory = client._session_factory @@ -159,6 +185,27 @@ def test_agents_render_separate_health_and_fiscal_status_with_server_sort(): assert 'value="worst_coverage"' in response.text +def test_agent_list_hides_technical_uuid_and_explains_remainder(): + client, agent_id, _ = _panel_client() + with client._session_factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + technical_uuid = installation.installation_id + agent = session.get(Agent, agent_id) + agent.agent_version = None + snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id)) + snapshot.version_drift = "UNKNOWN" + session.commit() + + response = client.get("/agents") + + assert response.status_code == 200 + assert technical_uuid not in response.text + assert "classificados" in response.text + assert "bloqueados com segurança" in response.text + assert "falhas técnicas" in response.text + assert "DesconhecidaDesconhecida" not in response.text + + def test_agent_list_and_detail_show_localized_last_seen_with_iso_tooltip(): client, agent_id, _ = _panel_client() expected_iso = '2026-09-22T19:11:37+00:00' @@ -310,6 +357,44 @@ def test_agent_detail_resolves_agent_after_first_hundred_installations(): assert "Late ERP 104" in response.text +def test_agent_detail_reconciles_coverage_and_formats_missing_technical_fields(): + client, agent_id, _ = _panel_client() + with client._session_factory() as session: + agent = session.get(Agent, agent_id) + agent.agent_version = None + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id)) + snapshot.version_drift = "UNKNOWN" + session.commit() + + response = client.get(f"/agents/{agent_id}") + + assert response.status_code == 200 + for label in ("Produtos", "Completos", "Bloqueados com segurança", "Falhas técnicas", "Não classificados", "Cobertura fiscal"): + assert label in response.text + assert "Não informado" in response.text + assert "Desconhecida" in response.text + assert "Desconhecida · Desconhecida" not in response.text + assert "ODS -" not in response.text + context = response.text.split("

Contexto da empresa

", 1)[1].split("", 1)[0] + assert "Reprocessar tudo" in context + assert not re.search(r"<(?:a|button)\b[^>]*>\s*", context) + + +def test_agent_detail_reports_negative_coverage_counts_as_data_inconsistency(): + client, agent_id, _ = _panel_client() + with client._session_factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id)) + snapshot.failed_products = -1 + session.commit() + + response = client.get(f"/agents/{agent_id}") + + assert response.status_code == 200 + assert "contagens negativas" in response.text + + def test_agent_event_filters_lookahead_and_safe_event_reference_links_and_confirmation(): client, agent_id, _ = _panel_client() factory = client._session_factory diff --git a/central/tests/test_operational_view_models.py b/central/tests/test_operational_view_models.py index 600ed5f..6482117 100644 --- a/central/tests/test_operational_view_models.py +++ b/central/tests/test_operational_view_models.py @@ -100,6 +100,70 @@ def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_produc assert summary["online"] == 1 and summary["safe_blocked"] == 2 +def test_snapshot_coverage_exposes_unclassified_residual_and_overcount(): + session_factory = setup_db() + with session_factory() as session: + tenant, agent, installation, snapshot = _installation(session, 1, coverage=80, blocked=1) + snapshot.total_products = 100 + snapshot.fiscally_complete = 96 + snapshot.safe_blocked = 1 + snapshot.failed_products = 1 + session.commit() + + item = list_installations(session, page_size=5)["items"][0] + assert item["unclassified_products"] == 2 + assert item["coverage_overcount_products"] == 0 + assert item["coverage_invariant_pass"] is True + assert item["total_products"] == item["fiscally_complete"] + item["safe_blocked"] + item["failed_products"] + item["unclassified_products"] + + snapshot.fiscally_complete = 99 + session.commit() + inconsistent = list_installations(session, page_size=5)["items"][0] + assert inconsistent["unclassified_products"] == 0 + assert inconsistent["coverage_overcount_products"] == 1 + assert inconsistent["coverage_invariant_pass"] is False + + +def test_dashboard_coverage_totals_reconcile_and_keep_registry_metric_separate(): + session_factory = setup_db() + with session_factory() as session: + tenant, _, installation, snapshot = _installation(session, 1, coverage=96, blocked=1) + snapshot.total_products = 100 + snapshot.fiscally_complete = 96 + snapshot.safe_blocked = 1 + snapshot.failed_products = 1 + _blocker(session, tenant, product="P-OPEN", field="CFOP", index=91, installation_id=installation.id) + session.commit() + + summary = dashboard_summary(session, now=NOW) + + assert summary["total_products"] == 100 + assert summary["fiscally_complete"] == 96 + assert summary["safe_blocked"] == 1 + assert summary["failed_products"] == 1 + assert summary["unclassified_products"] == 2 + assert summary["coverage_overcount_products"] == 0 + assert summary["coverage_invariant_pass"] is True + assert summary["safe_blocked_products"] == 1 + assert summary["total_products"] == summary["fiscally_complete"] + summary["safe_blocked"] + summary["failed_products"] + summary["unclassified_products"] + + +def test_dashboard_marks_inconsistent_snapshot_partition_instead_of_clamping(): + session_factory = setup_db() + with session_factory() as session: + _, _, _, snapshot = _installation(session, 1, coverage=100, blocked=2) + snapshot.total_products = 10 + snapshot.fiscally_complete = 9 + snapshot.safe_blocked = 2 + snapshot.failed_products = 0 + session.commit() + + summary = dashboard_summary(session, now=NOW) + + assert summary["coverage_overcount_products"] == 1 + assert summary["coverage_invariant_pass"] is False + + def test_attention_items_are_ranked_and_capped_at_ten(): session_factory = setup_db() with session_factory() as session: diff --git a/docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md b/docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md index f676ef1..4fb7348 100644 --- a/docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md +++ b/docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md @@ -21,15 +21,32 @@ Technical health describes Agent/installation availability and processing failures. Fiscal status and fiscal completion are separate: safe-blocked fiscal work does not make an otherwise healthy Agent technically degraded or failed. Agent views show product total, fiscally complete count, safe-blocked count, -failed count, and fiscal completion percentage. The percentage is +technical-failure count, unclassified residual, and fiscal completion percentage. +At installation/snapshot grain, coverage is reconciled as: + +```text +TOTAL_PRODUCTS = COMPLETE + SAFE_BLOCKED + FAILED_PRODUCTS + UNCLASSIFIED +``` + +`UNCLASSIFIED` is the positive residual `TOTAL_PRODUCTS - COMPLETE - +SAFE_BLOCKED - FAILED_PRODUCTS`: the snapshot did not assign those products to +one of its explicit states. It is an observability bucket, not a newly inferred +fiscal state. If explicit category counts exceed the total or contain negative +values, the panel shows an inconsistency instead of clamping it away. Fixture +snapshots assign all products explicitly and therefore reconcile to zero +unclassified products. The fiscal completion percentage is `fiscally_complete / total_products * 100`, rounded to one decimal place and bounded to 0–100; when the total is zero, the stored coverage percentage is used. The fiscal status badge is `BLOCKED` when safe-blocked work exists, `COMPLETE` when all products are complete, and otherwise `INCOMPLETE`. -Two blocker counts must remain distinct: +Coverage-state counts and blocker-registry counts have different sources and +must remain distinct: -- **Safe-blocked products**: distinct open `(tenant_id, product_code)` pairs. +- **Safe-blocked coverage products**: sum of the Agent snapshot safe-blocked + count at installation grain; this participates in the coverage invariant. +- **Products with open blockers**: distinct open `(tenant_id, product_code)` + pairs in the blocker registry. - **Open blockers**: open blocker rows; one product can have multiple rows for different fields or evidence. diff --git a/docs/superpowers/plans/2026-09-22-control-panel-final-predeploy-fixes-v1.md b/docs/superpowers/plans/2026-09-22-control-panel-final-predeploy-fixes-v1.md new file mode 100644 index 0000000..a78b1d1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-control-panel-final-predeploy-fixes-v1.md @@ -0,0 +1,81 @@ +# Control Panel Final Pre-Deploy Fixes V1 Implementation Plan + +> **For agentic workers:** Execute inline; the operator mission authorizes autonomous completion. Steps use checkbox syntax for tracking. + +**Goal:** Reconcile fiscal coverage counts, correct the fixture gap, and apply the narrowly scoped login/Agent-list/Agent-detail presentation fixes before deploy freeze. + +**Architecture:** Preserve the FastAPI/Jinja2/CSS/vanilla-JS panel and fiscal/Agent behavior. Add a derived, explicitly named `unclassified_products` residual from snapshot coverage, expose a consistency flag for over-counted categories, correct only fixture counts that are known synthetic gaps, and make requested UI-only changes. + +**Tech Stack:** Python 3.12, FastAPI, Jinja2, SQLAlchemy, CSS, pytest, Docker Compose local runtime. + +## Global Constraints + +- Do not deploy, alter fiscal engine/requiredness, call AI, enable Firebird writes, change architecture, add SPA, modify Proxmox, or integrate GTI Gerenciador. +- A product coverage state is counted at installation/snapshot grain; existing distinct blocker registry totals remain separately labeled. +- Define `UNCLASSIFIED = TOTAL - COMPLETE - SAFE_BLOCKED - FAILED_PRODUCTS` only when nonnegative; show an inconsistency warning and fail `coverage_invariant_pass` when the explicit categories exceed total. +- Do not use an unexplained generic “other” label; the residual is “Não classificados (sem estado informado)” and documentation says precisely that snapshot telemetry did not assign a known state. +- Keep Agent percentage and technical IDs in detail/API; remove UUID only from the main Agents table. +- Do not modify Firebird databases or run fixture generation against production/client data. + +--- + +### Task 1: Prove coverage gap and add view-model/UI tests + +**Files:** +- Modify `central/tests/test_operational_view_models.py` +- Modify `central/tests/test_control_panel_fixture.py` +- Modify `central/tests/test_control_panel_pages.py` +- Add focused assertions to `central/tests/test_control_plane.py` only if needed. + +- [ ] Test coverage partition arithmetic for normal, residual, and over-counted snapshots. +- [ ] Test dashboard sums total/complete/snapshot-safe-blocked/failed and explicit residual; require nonnegative invariant pass only when components do not exceed total. +- [ ] Test Agent detail/list expose explicit residual and the fiscal percentage. +- [ ] Test fixture total equals complete + safe-blocked + failed + residual; assert fixture residual zero after correction. +- [ ] Test login form labels/container, Agent list does not contain technical UUID, detail shows missing fields as `Não informado`/`Desconhecida`, and no empty green anchor/button exists in company card. +- [ ] Run targeted tests and capture expected failures before implementation. + +### Task 2: Implement reconciled coverage view-models and fixture + +**Files:** +- Modify `central/app/control_plane.py` +- Modify `central/scripts/generate_control_panel_fixture.py` +- Modify dashboard, Agent list, and Agent detail templates. + +**Interfaces:** +- Add a single helper returning `complete`, `safe_blocked`, `failed`, `unclassified`, `total`, `consistent`, and `overcount` for a snapshot. +- Agent view adds `unclassified_products`, `coverage_invariant_pass`, `coverage_overcount_products`. +- Dashboard returns `safe_blocked_coverage_products`, `unclassified_products`, `coverage_invariant_pass`, and `coverage_overcount_products`; retain registry `safe_blocked_products` as a separately labeled blocker metric if displayed. + +- [ ] Implement arithmetic with explicit consistency behavior; do not clamp or silently hide over-count. +- [ ] Dashboard SQL aggregate sums snapshot fields and computes residual at presentation/query aggregation without loading products. +- [ ] Fixture sets complete to `total - safe_blocked - failed_products`; set coverage percentage from complete/total rounded one decimal. +- [ ] Add template rows/cards for safe-blocked coverage, failed technical products, and unclassified products; display inconsistency warning when needed. +- [ ] Agent list explains the remaining percentage beside complete/blocked/failed/unclassified counts. +- [ ] Run targeted model/fixture/page tests green. + +### Task 3: Minimal visual fixes and technical missing values + +**Files:** +- Modify `central/templates/login.html` +- Modify `central/templates/agents.html` +- Modify `central/templates/agent_detail.html` +- Modify `central/static/control-panel.css` +- Modify `central/tests/test_control_panel_pages.py` + +- [ ] Add a login wrapper/card sizing and center layout, explicit `for`/`id` labels, spacing, responsive width, and existing accent colors. +- [ ] Remove the installation UUID secondary text from the Agents table; retain it in detail technical IDs/API. +- [ ] Replace empty/unknown technical displays with `Não informado` and `Desconhecida`; do not invent Firebird version/ODS or version drift. +- [ ] Investigate green empty control: confirm rendered anchor is a real reprocess link with visible text; if visually empty due styles, correct styles; if action absent, remove element. Add assertion for non-empty accessible name/text. +- [ ] Run focused page tests green. + +### Task 4: Documentation and final verification + +**Files:** +- Modify `docs/CONTROL_PANEL_V1.md` and/or `docs/CONTROL_PANEL_OPERATIONAL_UX_V1.md` only if required to define the residual precisely. + +- [ ] Document that unclassified is the snapshot residual without a supplied operational state, not a fiscal rule. +- [ ] Run exactly `python -m pytest tests -q`, `python -m pytest central/tests -q`, and `python -m pytest agent/tests -q`. +- [ ] Rebuild only local Compose `fiscal-hub`; verify PostgreSQL healthy and Hub `/health` 200. +- [ ] Smoke `/login`, `/`, `/agents`, `/agents/{id}`; check coverage reconciliation, login, UUID removal, visible reprocess control, and missing technical values. +- [ ] Measure route latency and record maximum. +- [ ] Verify flags, no deploy/Firebird operations, diff check, commits, and clean worktree; report exact requested final fields.