fix: show technical timestamps in tooltips
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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><a class="secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a></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>
|
||||
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2><a class="secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a></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_at }}">{{ 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 %} · <a href="/jobs?job_id={{ event.job_id }}">Job {{ event.job_id }}</a>{% endif %}{% if event.product_code %} · <a href="/blockers?agent_id={{ detail.agent_id }}&product_code={{ event.product_code|urlencode }}">Produto {{ event.product_code }}</a>{% 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><label>Desde<input type="date" name="since" value="{{ since }}"></label><label>Até<input type="date" name="until" value="{{ until }}"></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>{% if has_more %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page + 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Próxima</a>{% endif %}</nav></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers?agent_id={{ detail.agent_id }}">Ver todos</a></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ blocker_breakdown[key] }}</b></div>{% endfor %}</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>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{% 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>
|
||||
<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_at }}">{{ 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 %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>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>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>Primeira ocorrência</dt><dd title="{{ detail.first_seen_at }}">{{ detail.first_seen_display }}</dd><dt>Última ocorrência</dt><dd title="{{ detail.last_seen_at }}">{{ 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 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>
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
<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.affected_products }}</strong></article><article class="metric"><span>Clientes afetados</span><strong>{{ summary.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">{% if request.query_params.get('agent_id') %}<input type="hidden" name="agent_id" value="{{ request.query_params.get('agent_id') }}">{% endif %}{% if request.query_params.get('product_code') %}<input type="hidden" name="product_code" value="{{ request.query_params.get('product_code') }}">{% endif %}<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 filtrados</h2></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.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>
|
||||
<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_at }}">{{ 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 %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</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><div class="table-wrap"><table><caption>Falhas recentes (máximo 10)</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Job ID / Tipo</th><th>Erro</th><th>Criado</th><th>Finalizado</th></tr></thead><tbody>{% for job in summary.recent_failed_jobs %}<tr><td>{{ job.client or "-" }}</td><td>{{ job.display_name or "-" }}<small>{{ job.hostname or "hostname indisponível" }}</small></td><td><a href="{{ job.link }}">{{ job.job_id }}</a><small>{{ job.kind }}</small></td><td>{{ job.error or "Sem detalhe de erro" }}</td><td title="{{ job.created_age }}">{{ job.created_display }}<small>{{ job.created_age }}</small></td><td title="{{ job.finished_age }}">{{ job.finished_display }}<small>{{ job.finished_age }}</small></td></tr>{% endfor %}</tbody></table></div>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}</article>
|
||||
<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><div class="table-wrap"><table><caption>Falhas recentes (máximo 10)</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Job ID / Tipo</th><th>Erro</th><th>Criado</th><th>Finalizado</th></tr></thead><tbody>{% for job in summary.recent_failed_jobs %}<tr><td>{{ job.client or "-" }}</td><td>{{ job.display_name or "-" }}<small>{{ job.hostname or "hostname indisponível" }}</small></td><td><a href="{{ job.link }}">{{ job.job_id }}</a><small>{{ job.kind }}</small></td><td>{{ job.error or "Sem detalhe de erro" }}</td><td title="{{ job.created_at }}">{{ job.created_display }}<small>{{ job.created_age }}</small></td><td title="{{ job.finished_at }}">{{ job.finished_display }}<small>{{ job.finished_age }}</small></td></tr>{% endfor %}</tbody></table></div>{% 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 %}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
{% 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>
|
||||
<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_at }}">{{ item.created_display }}</td><td title="{{ item.finished_at }}">{{ 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 %}
|
||||
|
||||
@@ -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 ">ST</span><b>0</b>" 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():
|
||||
|
||||
Reference in New Issue
Block a user