diff --git a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-5-report.md b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-5-report.md index e365cd2..cf909b6 100644 --- a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-5-report.md +++ b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-5-report.md @@ -55,10 +55,25 @@ write/deployment boundaries. ## Concerns -- The new UX contract documents the design's full technical timestamp in the - title attribute. The inspected templates currently pass the human-formatted - timestamp as the title value; runtime code was outside this task's allowed - scope. Align the title value in a runtime task or revise the contract after - deciding the intended presentation. +- Resolved in the timestamp tooltip follow-up below: templates now use raw ISO + UTC timestamps in `title` and retain localized human-readable visible text. - Full central tests retain the two pre-existing dependency warnings described above; they did not fail the suite. + +## Timestamp tooltip follow-up + +Resolved the full technical timestamp concern: agent, job, blocker, and +populated dashboard failure timestamps now keep their localized human-readable +text visible and expose the complete ISO UTC timestamp (including seconds and +`+00:00`) in the `title` attribute. Blocker detail includes first- and +last-seen timestamps. The existing raw ISO serializer now explicitly normalizes +naive SQLite timestamps as UTC, matching the presentation layer's existing +interpretation without changing the view-model shape. + +### TDD and verification + +- RED: `python -m pytest central/tests/test_control_panel_pages.py -q -k "localized_last_seen or localized_created or localized_seen_times or bounded_failed_job_rows"` — 4 expected failures because the tooltips did not contain the required raw ISO values. +- GREEN: `python -m pytest central/tests/test_control_panel_pages.py -q` — passed (`12 passed`). +- Full suite: `python -m pytest central/tests -q` — passed (`278 passed`, one existing `openpyxl` warning; pytest-asyncio also emits its existing fixture-loop deprecation warning). +- `git diff --check` — passed. +- Commit message: `fix: show technical timestamps in tooltips`. diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 8231187..446da02 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -24,7 +24,10 @@ def _page(page: int, page_size: int): def _iso(value): - return value.isoformat() if value else None + if value is None: + return None + value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc) + return value.isoformat() def _utc_now(value=None): diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index aa71824..0312cb8 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -4,7 +4,7 @@

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

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

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 35a92fe..ff1c008 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 "-" }}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.
{% endblock %} diff --git a/central/templates/blocker_detail.html b/central/templates/blocker_detail.html index 9bc6a3a..d18ace2 100644 --- a/central/templates/blocker_detail.html +++ b/central/templates/blocker_detail.html @@ -4,7 +4,7 @@

RESOLUÇÃO OPERACIONAL

Blocker {{ detail.id }}

{{ detail.client or "Cliente não identificado" }} · {{ detail.product_code }}

Fiscal: {{ detail.blocker_type }}Prioridade: {{ detail.priority }}
{% if error %}

{{ error }}

{% endif %}
-

Detalhes do blocker

Cliente
{{ detail.client or "-" }}
Instalação
{{ detail.installation.display_name or "-" }}
Servidor
{{ detail.server.hostname or "-" }}
Produto
{{ detail.product_code }}
Descrição
{{ detail.description or "-" }}
Campo
{{ detail.field or "OTHER" }}
Tipo
{{ detail.blocker_type }}
Motivo
{{ detail.reason }}
Idade
{{ detail.age }}
Última ocorrência
{{ detail.last_seen_display }}
Evidência
{{ detail.evidence|tojson(indent=2) }}
+

Detalhes do blocker

Cliente
{{ detail.client or "-" }}
Instalação
{{ detail.installation.display_name or "-" }}
Servidor
{{ detail.server.hostname or "-" }}
Produto
{{ detail.product_code }}
Descrição
{{ detail.description or "-" }}
Campo
{{ detail.field or "OTHER" }}
Tipo
{{ detail.blocker_type }}
Motivo
{{ detail.reason }}
Idade
{{ detail.age }}
Primeira ocorrência
{{ detail.first_seen_display }}
Última ocorrência
{{ detail.last_seen_display }}
Evidência
{{ detail.evidence|tojson(indent=2) }}

Contexto atual

Valor atual por chave persistida para esta empresa.

{% for entry in context_entries %}
{{ entry.key }}{{ entry.scope }} / {{ entry.scope_key or "empresa" }}{{ entry.value }}
{% else %}

Nenhum valor definido.

{% endfor %}

Salvar contexto e reprocessar

Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.

Cancelar
diff --git a/central/templates/blockers.html b/central/templates/blockers.html index a76590a..6942ddc 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -5,6 +5,6 @@
Blockers abertos{{ summary.open_blockers }}
Produtos afetados{{ summary.affected_products }}
Clientes afetados{{ summary.affected_clients }}
Resultado filtrado{{ result.total }}
{% if request.query_params.get('agent_id') %}{% endif %}{% if request.query_params.get('product_code') %}{% endif %}

Distribuição dos blockers filtrados

{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ summary.blocker_breakdown[key] }}
{% endfor %}
-
{% for item in result["items"] %}{% else %}{% endfor %}
Fila de blockers
ClienteInstalaçãoProdutoCampoTipo de blockerPrioridadeProblemaÚltima ocorrênciaAção
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "hostname indisponível" }}{{ item.product_code }}{{ item.description or "" }}{{ item.field or "OTHER" }}Fiscal: {{ item.blocker_type }}Prioridade: {{ item.priority }}{{ item.reason }}{{ item.age }}{{ item.last_seen_display }}{{ item.retry_count }} tentativasVer detalhe
Nenhum blocker corresponde aos filtros.
+
{% for item in result["items"] %}{% else %}{% endfor %}
Fila de blockers
ClienteInstalaçãoProdutoCampoTipo de blockerPrioridadeProblemaÚltima ocorrênciaAção
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "hostname indisponível" }}{{ item.product_code }}{{ item.description or "" }}{{ item.field or "OTHER" }}Fiscal: {{ item.blocker_type }}Prioridade: {{ item.priority }}{{ item.reason }}{{ item.age }}{{ item.last_seen_display }}{{ item.retry_count }} tentativasVer detalhe
Nenhum blocker corresponde aos filtros.
{% endblock %} diff --git a/central/templates/dashboard.html b/central/templates/dashboard.html index b3e384f..0e4db17 100644 --- a/central/templates/dashboard.html +++ b/central/templates/dashboard.html @@ -11,7 +11,7 @@

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

Jobs com falha

Ver jobs
{% if summary.failed_jobs %}

{{ summary.failed_jobs }} jobs aguardam investigação.

{% for job in summary.recent_failed_jobs %}{% endfor %}
Falhas recentes (máximo 10)
ClienteInstalaçãoJob ID / TipoErroCriadoFinalizado
{{ job.client or "-" }}{{ job.display_name or "-" }}{{ job.hostname or "hostname indisponível" }}{{ job.job_id }}{{ job.kind }}{{ job.error or "Sem detalhe de erro" }}{{ job.created_display }}{{ job.created_age }}{{ job.finished_display }}{{ job.finished_age }}
{% else %}

Nenhum job com falha.

{% endif %}
+

Jobs com falha

Ver jobs
{% if summary.failed_jobs %}

{{ summary.failed_jobs }} jobs aguardam investigação.

{% for job in summary.recent_failed_jobs %}{% endfor %}
Falhas recentes (máximo 10)
ClienteInstalaçãoJob ID / TipoErroCriadoFinalizado
{{ job.client or "-" }}{{ job.display_name or "-" }}{{ job.hostname or "hostname indisponível" }}{{ job.job_id }}{{ job.kind }}{{ job.error or "Sem detalhe de erro" }}{{ job.created_display }}{{ job.created_age }}{{ job.finished_display }}{{ job.finished_age }}
{% else %}

Nenhum job com falha.

{% endif %}

Agents desatualizados

Ver agents
{% for item in summary.outdated_agents[:10] %}{{ item.client }} · {{ item.display_name }}{{ item.hostname or "hostname indisponível" }} · {{ item.agent_version or "versão desconhecida" }}Desatualizado{% else %}

Nenhum agent desatualizado.

{% endfor %}
{% endblock %} diff --git a/central/templates/jobs.html b/central/templates/jobs.html index 1919717..0e5a4b2 100644 --- a/central/templates/jobs.html +++ b/central/templates/jobs.html @@ -3,6 +3,6 @@ {% block content %}

EXECUÇÕES

Jobs globais

Acompanhe o que está em andamento e onde falhou.

-
{% for item in result["items"] %}{% else %}{% endfor %}
Execuções recentes
ClienteNome de exibiçãoHostnameJob IDTipoStatusCriadoFinalizadoDuraçãoErro
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "-" }}#{{ item.job_id }}Agent técnico {{ item.agent_id }}{{ item.kind }}Status: {{ item.status }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ item.error or "-" }}
Nenhum job encontrado para os filtros atuais.
+
{% for item in result["items"] %}{% else %}{% endfor %}
Execuções recentes
ClienteNome de exibiçãoHostnameJob IDTipoStatusCriadoFinalizadoDuraçãoErro
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "-" }}#{{ item.job_id }}Agent técnico {{ item.agent_id }}{{ item.kind }}Status: {{ item.status }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ item.error or "-" }}
Nenhum job encontrado para os filtros atuais.
{% endblock %} diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index cbc6f0c..ca199ff 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -48,6 +48,8 @@ def _panel_client(): }, installation_id=installation.id, ) + blocker.first_seen_at = now - timedelta(days=1, seconds=11) + blocker.last_seen_at = now.replace(second=37) record_event( session, tenant_id=installation.tenant_id, @@ -92,7 +94,7 @@ def test_dashboard_lists_bounded_failed_job_rows_with_identity_error_age_and_emp factory = client._session_factory with factory() as session: for index in range(12): - session.add(Job(agent_id=agent_id, kind="REPROCESS_PRODUCTS", status="FAILED", error=f"erro curto {index}", created_at=datetime(2026, 9, 22, 18, 0, tzinfo=timezone.utc))) + session.add(Job(agent_id=agent_id, kind="REPROCESS_PRODUCTS", status="FAILED", error=f"erro curto {index}", created_at=datetime(2026, 9, 22, 18, 0, index, tzinfo=timezone.utc), finished_at=datetime(2026, 9, 22, 18, 5, index, tzinfo=timezone.utc))) session.commit() response = client.get("/") assert response.status_code == 200 @@ -100,6 +102,10 @@ def test_dashboard_lists_bounded_failed_job_rows_with_identity_error_age_and_emp assert "erro curto" in response.text and "Job ID" in response.text and "Criado" in response.text assert 'href="/jobs?job_id=' in response.text assert response.text.count("erro curto") == 10 + assert 'title="2026-09-22T18:00:11+00:00"' in response.text + assert 'title="2026-09-22T18:05:11+00:00"' in response.text + assert ">22/09/2026 15:00<" in response.text + assert ">22/09/2026 15:05<" in response.text with factory() as session: session.query(Job).filter(Job.status == "FAILED").delete() session.commit() @@ -153,6 +159,26 @@ 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_localized_last_seen_with_iso_tooltip(): + client, agent_id, _ = _panel_client() + expected_iso = '2026-09-22T19:11:37+00:00' + expected_display = '22/09/2026 16:11' + with client._session_factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + installation.last_seen_at = datetime(2026, 9, 22, 19, 11, 37, tzinfo=timezone.utc) + session.commit() + + agents = client.get("/agents") + detail = client.get(f"/agents/{agent_id}") + + assert agents.status_code == 200 + assert f'title="{expected_iso}"' in agents.text + assert f'>{expected_display}<' in agents.text + assert detail.status_code == 200 + assert f'title="{expected_iso}"' in detail.text + assert f'>{expected_display} ·' in detail.text + + def test_jobs_render_client_identity_duration_and_preserve_filters(): client, _, _ = _panel_client() @@ -169,6 +195,36 @@ def test_jobs_render_client_identity_duration_and_preserve_filters(): assert "Agent 1" not in response.text +def test_jobs_show_localized_created_and_finished_with_iso_tooltips(): + client, agent_id, _ = _panel_client() + with client._session_factory() as session: + session.add(Job(agent_id=agent_id, kind="REPROCESS_PRODUCTS", status="COMPLETED", created_at=datetime(2026, 9, 22, 19, 8, tzinfo=timezone.utc), started_at=datetime(2026, 9, 22, 19, 9, tzinfo=timezone.utc), finished_at=datetime(2026, 9, 22, 19, 10, tzinfo=timezone.utc))) + session.commit() + + response = client.get("/jobs") + + assert response.status_code == 200 + assert 'title="2026-09-22T19:08:00+00:00"' in response.text + assert 'title="2026-09-22T19:10:00+00:00"' in response.text + assert ">22/09/2026 16:08<" in response.text + assert ">22/09/2026 16:10<" in response.text + + +def test_blocker_list_and_detail_show_localized_seen_times_with_iso_tooltips(): + client, _, blocker_id = _panel_client() + + listing = client.get("/blockers") + detail = client.get(f"/blockers/{blocker_id}") + + assert listing.status_code == 200 + assert 'title="2026-09-22T19:11:37+00:00"' in listing.text + assert ">22/09/2026 16:11<" in listing.text + assert detail.status_code == 200 + assert 'title="2026-09-21T19:10:49+00:00"' in detail.text + assert 'title="2026-09-22T19:11:37+00:00"' in detail.text + assert ">22/09/2026 16:11<" in detail.text + + def test_blockers_render_visible_filters_summary_priority_and_detail_link(): client, _, blocker_id = _panel_client() @@ -203,7 +259,7 @@ def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows(): assert ">ST0" in agent_response.text agent_blockers = client.get(f"/blockers?agent_id={agent_id}") assert "Produto de teste" in agent_blockers.text and "LEGACY" not in agent_blockers.text - assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", agent_response.text) + assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", re.sub(r' title="[^"]*"', "", agent_response.text)) assert 'value="WARNING" selected' in agent_response.text assert 'value="PRODUCT_BLOCKED"' in agent_response.text @@ -212,7 +268,7 @@ def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows(): 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) + assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", re.sub(r' title="[^"]*"', "", blocker_response.text)) def test_agent_detail_resolves_agent_after_first_hundred_installations():