From c950ffb47f9f02258d6a72d77bfda611cb31fc2f Mon Sep 17 00:00:00 2001 From: gti Date: Thu, 24 Sep 2026 14:26:44 -0300 Subject: [PATCH] feat: UI polish - dark default, version drift vs default release, humanized enums, failed-jobs layout, processing button hierarchy, PT-BR titles/CTAs Co-Authored-By: Claude Sonnet 5 --- central/app/api.py | 4 +- central/app/control_api.py | 3 +- central/app/control_plane.py | 28 +++++---- central/app/installation_identity.py | 50 +++++++++++++-- central/app/panel_presentation.py | 24 ++++++- central/app/web.py | 6 +- central/static/control-panel.css | 22 +++++++ central/static/tema.js | 2 +- central/templates/agent_detail.html | 16 ++--- central/templates/agents.html | 2 +- central/templates/base.html | 5 +- central/templates/blocker_detail.html | 4 +- central/templates/blockers.html | 2 +- central/templates/dashboard.html | 14 ++--- central/templates/jobs.html | 2 +- central/tests/test_control_panel_pages.py | 42 +++++++++++-- central/tests/test_operational_view_models.py | 10 +++ central/tests/test_version_reference.py | 63 +++++++++++++++++++ 18 files changed, 247 insertions(+), 52 deletions(-) create mode 100644 central/tests/test_version_reference.py diff --git a/central/app/api.py b/central/app/api.py index e9f4a7d..56faa1e 100644 --- a/central/app/api.py +++ b/central/app/api.py @@ -15,7 +15,7 @@ from .blocked_products import list_blockers, resolve_blocker, resolve_product_bl from .context_keys import validate_entry from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job from .events import record_event -from .installation_identity import compute_agent_health, compute_version_drift, sanitize_registration +from .installation_identity import compute_agent_health, compute_version_drift, reference_agent_version, sanitize_registration from .control_plane import list_jobs from .human_auth import require_human @@ -201,7 +201,7 @@ def router(session_factory, bootstrap_token): snapshot.failed_products = int(coverage.get("failed_products", snapshot.failed_products) or 0) snapshot.coverage_percent = float(coverage.get("coverage_percent", snapshot.coverage_percent) or 0) snapshot.blockers = coverage.get("blockers", snapshot.blockers) or {} - snapshot.version_drift = compute_version_drift(agent.agent_version) + snapshot.version_drift = compute_version_drift(agent.agent_version, reference_agent_version(session)) snapshot.agent_health, snapshot.health_reason = compute_agent_health(installation, supplemental=snapshot) session.add(agent); session.flush() record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="HEARTBEAT", message="Agent heartbeat", context={"status": agent.last_status, "agent_version": agent.agent_version}) diff --git a/central/app/control_api.py b/central/app/control_api.py index d19f24e..9c60f3b 100644 --- a/central/app/control_api.py +++ b/central/app/control_api.py @@ -7,6 +7,7 @@ from sqlalchemy import select from .audit import record_audit from .blocker_resolution import BlockerResolutionUnavailable, resolve_blocker_with_context +from .installation_identity import reference_agent_version from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events from .human_auth import require_csrf, require_human from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant @@ -46,7 +47,7 @@ def router(session_factory): session, _, _ = identity try: installation, agent, tenant, snapshot = get_installation(session, agent_id) - return installation_view(installation, agent, tenant, snapshot) + return installation_view(installation, agent, tenant, snapshot, reference=reference_agent_version(session)) finally: session.close() @routes.get("/agents/{agent_id}/health") diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 5acebc7..45ba38d 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -10,9 +10,9 @@ from sqlalchemy import Numeric, and_, case, cast, func, or_, select from .config import settings from .events import list_events -from .installation_identity import compute_agent_health, compute_version_drift +from .installation_identity import compute_agent_health, compute_version_drift, outdated_versions, reference_agent_version from .models import Agent, AgentEnrollment, AgentEvent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant -from .panel_presentation import blocker_reason_label, blocker_type_label, fiscal_status_label, format_age, format_duration, format_ui_datetime, health_label, job_kind_label, job_status_label, operational_priority, priority_label +from .panel_presentation import health_reason_label, blocker_reason_label, blocker_type_label, fiscal_status_label, format_age, format_duration, format_ui_datetime, health_label, job_kind_label, job_status_label, operational_priority, priority_label HEALTH_STATES = ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN") @@ -131,13 +131,13 @@ def _attention_item(item, problem, timestamp, *, rank, link=None, now=None): } -def installation_view(installation, agent, tenant, snapshot=None, *, now=None): +def installation_view(installation, agent, tenant, snapshot=None, *, now=None, reference=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 not version_drift or version_drift == "UNKNOWN": - version_drift = compute_version_drift(agent.agent_version) + # Always recomputed against the current reference release: the value stored on the snapshot was taken at the + # last heartbeat (against whatever was "current" then) and goes stale as soon as a newer release is published. + version_drift = compute_version_drift(agent.agent_version, reference) fiscal_status = _fiscal_status(snapshot) coverage_partition = _coverage_partition(snapshot) if not has_snapshot: @@ -213,6 +213,7 @@ def _installation_query_filters(query, *, client=None, version=None, search=None def list_installations(session, *, page=1, page_size=50, client=None, health=None, version=None, search=None, last_seen_before=None, sort="display_name", scope="active", now=None): page, page_size = _page(page, page_size) now = _utc_now(now) + reference = reference_agent_version(session) query = select(Installation, Agent, Tenant, AgentSnapshot, operational_installation_predicate().label("operational_active")).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id) if scope == "active": query = query.where(operational_installation_predicate()) @@ -220,7 +221,7 @@ def list_installations(session, *, page=1, page_size=50, client=None, health=Non raise ValueError("scope must be active or all") query = _installation_query_filters(query, client=client, version=version, search=search, last_seen_before=last_seen_before) if version == "outdated": - query = query.where(or_(AgentSnapshot.version_drift == "OUTDATED", and_(Agent.agent_version.is_not(None), Agent.agent_version != settings.agent_server_version))) + query = query.where(Agent.agent_version.in_(outdated_versions(session, reference))) health_expression = _health_expression(now) if health: query = query.where(health_expression == health) @@ -229,7 +230,7 @@ def list_installations(session, *, page=1, page_size=50, client=None, health=Non else: ordered = query.order_by(Installation.display_name.asc(), Installation.id.asc()) rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all() - items = [{**installation_view(i, a, t, s, now=now), "operational_active": bool(active)} for i, a, t, s, active in rows] + items = [{**installation_view(i, a, t, s, now=now, reference=reference), "operational_active": bool(active)} for i, a, t, s, active in rows] count_query = select(func.count()).select_from(query.with_only_columns(Installation.id).order_by(None).subquery()) total = session.scalar(count_query) or 0 return {"page": page, "page_size": page_size, "items": items, "total": total, "health_filter_applied": health} @@ -430,6 +431,7 @@ def jobs_status_summary(session, *, scope="active"): def dashboard_summary(session, now=None): now = _utc_now(now) + reference = reference_agent_version(session) active = operational_installation_predicate() installation_query = 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(active) health_expression = _health_expression(now) @@ -483,12 +485,12 @@ def dashboard_summary(session, now=None): def installation_candidates(health): query = installation_query.where(health_expression == health).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT) - return [installation_view(i, a, t, s, now=now) for i, a, t, s in session.execute(query).all()] + return [installation_view(i, a, t, s, now=now, reference=reference) for i, a, t, s in session.execute(query).all()] health_candidates = {health: installation_candidates(health) for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN")} - version_filter = or_(AgentSnapshot.version_drift == "OUTDATED", and_(Agent.agent_version.is_not(None), Agent.agent_version != settings.agent_server_version)) + version_filter = Agent.agent_version.in_(outdated_versions(session, reference)) outdated_query = installation_query.where(version_filter).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT) - outdated_agents = [installation_view(i, a, t, s, now=now) for i, a, t, s in session.execute(outdated_query).all()] + outdated_agents = [installation_view(i, a, t, s, now=now, reference=reference) for i, a, t, s in session.execute(outdated_query).all()] group_field = func.upper(func.coalesce(BlockedProduct.field, "OTHER")) fiscal_priority = case( @@ -512,13 +514,13 @@ def dashboard_summary(session, now=None): attention = [] for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN"): rank = {"FAILED": 0, "DEGRADED": 1, "OFFLINE": 2, "UNKNOWN": 3}[health] - attention.extend((_attention_item(item, item["health_reason"], _technical_timestamp(item), rank=rank, now=now), rank, item["agent_id"]) for item in health_candidates[health]) + attention.extend((_attention_item(item, health_reason_label(item["health_reason"]), _technical_timestamp(item), rank=rank, now=now), rank, item["agent_id"]) for item in health_candidates[health]) for client_name, display_name, hostname, agent_id, field, blocker_type, reason, count, products, last_seen, in blocker_groups: item = {"client": client_name, "display_name": display_name, "hostname": hostname, "agent_id": agent_id, "technical_health": "BLOCKED"} link = "/blockers?" + urlencode({"scope": "active", "status": "OPEN", "agent_id": agent_id, "field": field, "blocker_type": blocker_type, "reason_code": reason}) priority = operational_priority({"field": field, "blocker_type": blocker_type, "reason_code": reason}) rank = {"CRITICAL": 3, "HIGH": 4, "NORMAL": 5, "LOW": 6}[priority] - entry = _attention_item(item, f"{field}: {reason}", last_seen, rank=rank, link=link, now=now) + entry = _attention_item(item, f"{field}: {blocker_reason_label(reason)}", last_seen, rank=rank, link=link, now=now) entry.update(group_count=count, products_affected=products, field=field, priority=priority) attention.append((entry, rank, agent_id)) for item in outdated_agents: diff --git a/central/app/installation_identity.py b/central/app/installation_identity.py index c077e78..0f87795 100644 --- a/central/app/installation_identity.py +++ b/central/app/installation_identity.py @@ -94,11 +94,53 @@ def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental= return "ONLINE", "recent heartbeat without critical technical failure" -def compute_version_drift(agent_version: str | None) -> str: +_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.]?(rc|beta|alpha|b|a)[-_.]?(\d*))?$", re.IGNORECASE) +_PRE_RANK = {"alpha": -3, "a": -3, "beta": -2, "b": -2, "rc": -1} + + +def parse_version(value: str | None): + """Comparable tuple for '1.1.7', '1.0.0rc1', ... (a pre-release sorts below its final); None if unparsable.""" + match = _VERSION_RE.match((value or "").strip()) + if not match: + return None + parts = [int(x) for x in match.group(1).split(".")] + while len(parts) < 4: + parts.append(0) + pre = (_PRE_RANK[match.group(2).lower()], int(match.group(3) or 0)) if match.group(2) else (0, 0) + return (*parts, *pre) + + +def reference_agent_version(session) -> str: + """The version an Agent should be running: the highest PUBLISHED Windows Agent release (REVOKED/DRAFT are + never a target). Falls back to the configured server version when no release is published.""" + from sqlalchemy import select + + from .models import AgentRelease + + best, best_key = None, None + for version in session.scalars(select(AgentRelease.version).where(AgentRelease.status == "PUBLISHED", AgentRelease.platform == "windows")): + key = parse_version(version) + if key is not None and (best_key is None or key > best_key): + best, best_key = version, key + return best or settings.agent_server_version + + +def compute_version_drift(agent_version: str | None, reference: str | None = None) -> str: + """CURRENT when the Agent is at (or ahead of) the reference release, OUTDATED when behind it.""" if not agent_version: return "UNKNOWN" - return "CURRENT" if agent_version == settings.agent_server_version else "OUTDATED" + reference = reference or settings.agent_server_version + have, want = parse_version(agent_version), parse_version(reference) + if have is None or want is None: + return "CURRENT" if agent_version == reference else "OUTDATED" + return "CURRENT" if have >= want else "OUTDATED" -def machine_fingerprint(parts: list[str]) -> str: - return hashlib.sha256("|".join(parts).encode()).hexdigest() +def outdated_versions(session, reference: str) -> list[str]: + """Distinct Agent versions in use that are behind the reference (used to keep SQL filters exact).""" + from sqlalchemy import select + + from .models import Agent + + used = [v for v in session.scalars(select(Agent.agent_version).where(Agent.agent_version.is_not(None)).distinct())] + return [v for v in used if compute_version_drift(v, reference) == "OUTDATED"] diff --git a/central/app/panel_presentation.py b/central/app/panel_presentation.py index ef77de0..6203936 100644 --- a/central/app/panel_presentation.py +++ b/central/app/panel_presentation.py @@ -121,11 +121,31 @@ def job_kind_label(value): def blocker_type_label(value): - return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão"}) + return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão", "OTHER": "Outro"}) + + +_REASON_LABELS = { + "INVALID_NCM": "NCM inválido", "LEGACY_NCM": "NCM legado", "LEGACY_NCM_NO_AUTOMATIC_SUCCESSOR": "NCM legado sem sucessor automático", + "LEGACY_NCM_REVIEW": "NCM legado em revisão", "LEGACY_NCM_REVIEW_REQUIRED": "NCM legado exige revisão", "LEGACY_OR_INVALID_NCM": "NCM legado ou inválido", + "INSUFFICIENT_EVIDENCE": "Evidência insuficiente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", + "MISSING_CONTEXT": "Contexto fiscal ausente", "NO_EVIDENCE": "Sem evidência", "MISSING_PRODUCT_ORIGIN": "Origem do produto ausente", + "MISSING_PROFILE": "Perfil fiscal ausente", "MISSING_ST_DETERMINATION": "Determinação de ST ausente", "NO_IPI_DECISION": "Sem decisão de IPI", + "NO_NUMERIC_VALUE": "Sem valor numérico", "CHANGE_REQUIRED": "Alteração necessária", "SAME_VALUE": "Valor já correto", "AMBIGUOUS": "Ambíguo", +} def blocker_reason_label(value): - return _human_label(value, {"INVALID_NCM": "NCM inválido", "INSUFFICIENT_EVIDENCE": "Evidência insuficiente", "MISSING_CONTEXT": "Contexto fiscal ausente", "NO_EVIDENCE": "Sem evidência"}) + return _human_label(value, _REASON_LABELS) + + +def health_reason_label(value): + """Operator wording for the technical health reason strings produced by compute_agent_health.""" + text = str(value or "") + if text.startswith("last heartbeat exceeds"): + return "Sem heartbeat recente" + return {"no heartbeat received": "Nenhum heartbeat recebido", "persistent technical failure": "Falha técnica persistente", + "recent technical or job failure": "Falha técnica ou de job recente", + "recent heartbeat without critical technical failure": "Operando normalmente"}.get(text, text or "Não informado") def priority_label(value): diff --git a/central/app/web.py b/central/app/web.py index 653596d..33c5eb1 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -14,6 +14,8 @@ from . import agent_distribution, client_sync, config, enrollment, sso from .config import settings from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context from .context_keys import ALLOWED_KEYS, ENUMS +from .installation_identity import reference_agent_version +from .panel_presentation import job_kind_label, job_status_label from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events from .human_auth import create_session, current_session, require_csrf, verify_password from .panel_presentation import format_ui_datetime @@ -275,7 +277,7 @@ def router(session_factory): 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) + detail = installation_view(installation, agent, tenant, snapshot, reference=reference_agent_version(session)) 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 @@ -295,7 +297,7 @@ def router(session_factory): next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds) return {"enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60, "last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None, - "last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id} if last_job else None} + "last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id, "kind_label": job_kind_label(last_job.kind), "status_label": job_status_label(last_job.status)} if last_job else None} async def _installation_action(request, agent_id, action): session, auth_data, redirect = page_auth(request) diff --git a/central/static/control-panel.css b/central/static/control-panel.css index 754feb1..ae27ff9 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -208,3 +208,25 @@ code { overflow-wrap: anywhere; } @media (max-width: 768px) { .cards, .cards.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 520px) { .cards, .cards.compact, .bar-grid { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 520px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .cards .metric { padding: 12px; } .cards .metric strong { font-size: 1.5rem; } } + +/* ---------- detalhe do Agent: badges de status e hierarquia das ações de processamento ---------- */ +.status-stack { display: flex; flex-wrap: wrap; gap: 16px; } +.status-block { display: grid; gap: 4px; justify-items: start; } +.status-label { font-size: 0.7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--cor-texto-secundario); } +.ops-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.ops-actions form { margin: 0; } +button.caution { background: var(--cor-aviso-fundo); color: var(--cor-aviso); border: 1px solid var(--cor-aviso); } +button.caution:hover { filter: brightness(1.08); } +.ops-actions .botao.secondary, .ops-actions button.secondary { background: transparent; } + +/* ---------- Jobs com falha (Resumo): lista sem tabela, erro resumido, sem scroll horizontal ---------- */ +.failed-jobs { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; } +.failed-job { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 10px 0 10px 12px; border-bottom: 1px solid var(--cor-borda); border-left: 3px solid var(--cor-perigo); margin-bottom: 8px; border-radius: 4px; } +.failed-job:last-child { border-bottom: 0; } +.failed-job-main { min-width: 0; display: grid; gap: 2px; } +.failed-job-main b, .failed-job-main small { overflow-wrap: anywhere; } +.failed-job-kind { font-size: 0.82rem; font-weight: 600; } +.failed-job .job-error-summary { max-width: none; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.failed-job-side { flex: none; display: grid; gap: 4px; justify-items: end; font-size: 0.8rem; color: var(--cor-texto-secundario); white-space: nowrap; } +@media (max-width: 520px) { .failed-job { flex-direction: column; } .failed-job-side { justify-items: start; grid-auto-flow: column; gap: 12px; } } +.ops-actions button:disabled, .ops-actions .primary-action:disabled { opacity: .45; cursor: not-allowed; filter: grayscale(.4); } diff --git a/central/static/tema.js b/central/static/tema.js index eca36fb..77a7f2b 100644 --- a/central/static/tema.js +++ b/central/static/tema.js @@ -6,7 +6,7 @@ function temaAtual() { var salvo = localStorage.getItem("tema"); if (salvo === "claro" || salvo === "escuro") return salvo; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro"; + return "escuro"; // padrão do painel sem preferência salva } botao.addEventListener("click", function () { diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 17ac25d..9a86e0d 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -1,17 +1,17 @@ {% extends "base.html" %} -{% block title %}{{ detail.display_name }} | GTI Fiscal{% endblock %} +{% block title %}{{ detail.display_name or detail.hostname or "Agent" }} | 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 }}
+

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

Processamento

Controlado pelo Hub — nenhuma configuração local.

-
-Reprocessar tudo -{% if ops.enabled %}
{% else %}
{% endif %} -
Processamento
{{ "Ativo" if ops.enabled else "Pausado" }} · sincronização automática a cada {{ ops.interval_minutes }} min
Última sincronização
{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}
Sincronização agendada
{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}
Último job
{% if ops.last_job %}{{ ops.last_job.kind }} · {{ ops.last_job.status }}{% else %}nenhum{% endif %}
+
+Reprocessar tudo +{% if ops.enabled %}
{% else %}
{% endif %} +
Processamento
{{ "Ativo" if ops.enabled else "Pausado" }} · sincronização automática a cada {{ ops.interval_minutes }} min
Última sincronização
{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}
Sincronização agendada
{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}
Último job
{% if ops.last_job %}{{ ops.last_job.kind_label }} · {{ ops.last_job.status_label }}{% else %}nenhum{% 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 }}
+

Contexto da empresa

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

Blockers abertos

Ver blockers
{% 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_label }} · {{ item.age }}{{ item.blocker_type_label }}{% else %}

Nenhum blocker aberto.

{% endfor %}
{% endblock %} diff --git a/central/templates/agents.html b/central/templates/agents.html index 3ef3c99..fd4c401 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Agents | GTI Fiscal{% endblock %} +{% block title %}Agents / Servidores | GTI Fiscal{% endblock %} {% block content %}

INVENTÁRIO OPERACIONAL

Agents / Servidores

Clientes e servidores ativos. Use Histórico para consultar instalações inativas ou revogadas.

+ Nova instalação Releases
diff --git a/central/templates/base.html b/central/templates/base.html index 002ea9e..faeaed8 100644 --- a/central/templates/base.html +++ b/central/templates/base.html @@ -11,9 +11,8 @@ diff --git a/central/templates/blocker_detail.html b/central/templates/blocker_detail.html index 98d3219..1dd375a 100644 --- a/central/templates/blocker_detail.html +++ b/central/templates/blocker_detail.html @@ -1,10 +1,10 @@ {% extends "base.html" %} {% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %} {% block content %} -

RESOLUÇÃO OPERACIONAL

Blocker {{ detail.id }}

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

Fiscal: {{ detail.blocker_type }}Prioridade: {{ detail.priority }}
+

RESOLUÇÃO OPERACIONAL

Blocker {{ detail.id }}

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

Tipo{{ detail.blocker_type_label }}
Prioridade{{ detail.priority_label }}
{% 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 }}
Primeira ocorrência
{{ detail.first_seen_display }}
Ú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_label }}
Motivo
{{ detail.reason_label }}
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 %}
{% if resolution_available %} diff --git a/central/templates/blockers.html b/central/templates/blockers.html index 5fd6baf..5af3ade 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -28,7 +28,7 @@

Distribuição dos blockers filtrados

{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}{% if summary.blocker_breakdown[key] > 0 %}{{ key }}{{ summary.blocker_breakdown[key] }}{% else %}
{{ key }}0
{% endif %}{% endfor %}
-{% for item in result["items"] %}{% else %}{% endfor %} +{% for item in result["items"] %}{% else %}{% endfor %}
Fila de blockers · {{ result.total }} resultados
Cliente / servidorProdutoCampo / tipoPrioridadeProblemaÚltima ocorrênciaAção
{{ item.client or "Cliente não identificado" }}{{ item.hostname or item.display_name or "Instalação não informada" }}{{ item.product_code }}{{ item.description or "Sem descrição" }}{{ item.field or "Outros" }}{{ item.blocker_type_label }}{{ item.priority_label }}{{ item.reason_label }}{{ item.age }}{{ item.last_seen_display }}{{ item.retry_count }} tentativasVer detalhe
Nenhum blocker corresponde aos filtros. Consultar histórico
{{ item.client or "Cliente não identificado" }}{{ item.hostname or item.display_name or "Instalação não informada" }}{{ item.product_code }}{{ item.description or "Sem descrição" }}{{ item.field or "Outros" }}{{ item.blocker_type_label }}{{ item.priority_label }}{{ item.reason_label }}{{ item.age }}{{ item.last_seen_display }}{{ item.retry_count }} tentativasVer detalhes
Nenhum blocker corresponde aos filtros. Consultar histórico
{% endblock %} diff --git a/central/templates/dashboard.html b/central/templates/dashboard.html index 2b5cf59..7266740 100644 --- a/central/templates/dashboard.html +++ b/central/templates/dashboard.html @@ -14,7 +14,7 @@
-

Cobertura dos produtos

Ver instalações
+

Cobertura dos produtos

Ver servidores
{% for key,label,url in [("total_products","Produtos avaliados","/agents?scope=active"),("fiscally_complete","Completos","/agents?scope=active&sort=worst_coverage"),("safe_blocked","Bloqueados com segurança","/blockers?scope=active&status=OPEN"),("failed_products","Falhas técnicas","/agents?scope=active&health=FAILED"),("unclassified_products","Não classificados","/agents?scope=active&sort=worst_coverage")] %} {{ summary[key] }}{{ label }} @@ -24,7 +24,7 @@
Produtos com blockers abertos: {{ summary.safe_blocked_products }}Blockers abertos: {{ summary.open_blockers }}
-

FILA DE AÇÃO

Atenção necessária

Ver todos
+

FILA DE AÇÃO

Atenção necessária

Ver blockers
{% for item in summary.attention_items %} {{ item.client or "Cliente não identificado" }}{{ item.hostname or item.display_name or "Servidor sem nome" }} · {{ item.problem }}{% if item.group_count %}{{ item.products_affected }} produtos afetados · {{ item.group_count }} blockers abertos · Ver blockers →{% endif %}{{ item.age }} {% else %}

Nenhum item requer atenção imediata.

{% endfor %} @@ -41,12 +41,12 @@

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)
Cliente / servidorTipo / erroFalhouAção
{{ job.client or "Cliente não identificado" }}{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}{{ job.kind_label }}{{ job.error or "Sem detalhe de erro" }}{{ job.finished_display }}{{ job.finished_age }}Ver job #{{ job.job_id }}
{% else %}

Nenhum job com falha.

{% endif %} +
    + {% for job in summary.recent_failed_jobs %}
  • {{ job.client or "Cliente não identificado" }}{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}{{ job.kind_label }}{{ job.error or "Sem detalhe de erro" }}
    {{ job.finished_age }}{{ job.finished_display }}Ver detalhes
  • {% endfor %} +
{% else %}

Nenhum job com falha.

{% endif %}
diff --git a/central/templates/jobs.html b/central/templates/jobs.html index 18f7a04..8988427 100644 --- a/central/templates/jobs.html +++ b/central/templates/jobs.html @@ -16,7 +16,7 @@ {% if status_filter or job_id_filter or scope_filter=="all" %}

Filtros ativos: {% if scope_filter=="all" %}Histórico e inativos{% endif %}{% if status_filter %}Status: {{ {"PENDING":"Pendente","RUNNING":"Executando","COMPLETED":"Concluído","FAILED":"Falhou"}[status_filter] }}{% endif %}{% if job_id_filter %}Job #{{ job_id_filter }}{% endif %}

{% endif %} {% if job_id_filter and result["items"] %}{% set selected = result["items"][0] %}

Job #{{ selected.job_id }}

Voltar à lista

{{ selected.client or "Cliente não identificado" }} · {{ selected.display_name or "Instalação" }} · {{ selected.hostname or "Servidor não informado" }}

{{ selected.kind_label }} {{ selected.status_label }}

Erro completo

{{ selected.error or "Sem erro registrado" }}
Tipo técnico: {{ selected.kind }} · Status técnico: {{ selected.status }}
{% endif %}
-{% for item in result["items"] %}{% else %}{% endfor %} +{% for item in result["items"] %}{% else %}{% endfor %}
Execuções recentes · {{ result.total }} resultados
Cliente / servidorTipo / jobStatusCriadoFinalizado / duraçãoErro / ação
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}{{ item.kind_label }}Job #{{ item.job_id }}{{ item.status_label }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}Ver job
Nenhum job encontrado. Consultar histórico
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}{{ item.kind_label }}Job #{{ item.job_id }}{{ item.status_label }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}Ver detalhes
Nenhum job encontrado. Consultar histórico
{% endblock %} diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index 8bdfcd4..a7fd750 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -1,3 +1,4 @@ +from pathlib import Path import re from datetime import datetime, timedelta, timezone @@ -85,7 +86,7 @@ def test_dashboard_renders_operational_queue_labels_and_human_dates(): 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 + assert "Ver blockers" in response.text assert "Jobs com falha" in response.text assert "Agents desatualizados" in response.text assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", response.text) @@ -188,9 +189,9 @@ def test_dashboard_lists_bounded_failed_job_rows_with_identity_error_age_and_emp response = client.get("/") assert response.status_code == 200 assert "Acme" in response.text and "Acme ERP" in response.text and "srv-01" in response.text - assert "erro curto" in response.text and "Tipo / erro" in response.text and "Falhou" in response.text + assert "erro curto" in response.text and "Falhas recentes" in response.text and "Falhou" in response.text assert 'href="/jobs?scope=active&status=FAILED&job_id=' in response.text - assert response.text.count("erro curto") == 10 + assert response.text.count('class="error-text job-error-summary"') == 10 # bounded, one clamped summary per row assert 'title="2026-09-22T18:05:11+00:00"' in response.text assert ">22/09/2026 15:05<" in response.text with factory() as session: @@ -458,7 +459,8 @@ def test_agent_detail_reconciles_coverage_and_formats_missing_technical_fields() 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 "Reprocessar tudo" not in context # the only reprocess action lives in the Processamento panel + assert response.text.count("Reprocessar tudo") == 1 assert not re.search(r"<(?:a|button)\b[^>]*>\s*", context) @@ -502,3 +504,35 @@ def test_agent_event_filters_lookahead_and_safe_event_reference_links_and_confir assert f'href="/agents/{agent_id}"' in confirmation.text assert client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": "wrong", "confirm": "yes"}).status_code == 403 assert client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": csrf, "confirm": "yes"}, follow_redirects=False).status_code == 303 + + +def test_theme_defaults_to_dark_and_never_overrides_a_saved_choice(): + client, _, _ = _panel_client() + base = client.get("/agents").text + # no saved preference -> dark; an explicit "claro" is the only thing that yields light + assert 'tema === "claro" ? "claro" : "escuro"' in base + assert "prefers-color-scheme" not in (Path(__file__).parents[1] / "static" / "tema.js").read_text(encoding="utf-8") + script = (Path(__file__).parents[1] / "static" / "tema.js").read_text(encoding="utf-8") + assert 'return "escuro"' in script and 'localStorage.setItem("tema", novo)' in script + for page in ("login.html", "logged_out.html", "sso_error.html"): + assert 'data-tema="escuro"' in (Path(__file__).parents[1] / "templates" / page).read_text(encoding="utf-8") + + +def test_operator_pages_use_portuguese_titles_and_consistent_ctas(): + client, agent_id, _ = _panel_client() + assert "Agents / Servidores | GTI Fiscal" in client.get("/agents").text + assert "Blockers | GTI Fiscal" in client.get("/blockers").text + assert "Jobs | GTI Fiscal" in client.get("/jobs").text + dashboard = client.get("/").text + for old in ("Abrir fila", "Ver instalações", "Ver todos", "Ver job #"): + assert old not in dashboard + + +def test_agent_detail_processing_hierarchy_and_status_badges(): + client, agent_id, _ = _panel_client() + page = client.get(f"/agents/{agent_id}").text + assert page.count("Reprocessar tudo") == 1 + assert 'class="primary-action"' in page and 'class="botao secondary"' in page and 'class="caution"' in page + assert "Mantém o heartbeat, mas não executa jobs" in page and "Recalcula todos os produtos" in page + assert "Saúde técnica: " not in page and "Status fiscal: " not in page # context lives in the small label, not in the badge + assert 'Saúde técnica' in page diff --git a/central/tests/test_operational_view_models.py b/central/tests/test_operational_view_models.py index bb1a817..afedafd 100644 --- a/central/tests/test_operational_view_models.py +++ b/central/tests/test_operational_view_models.py @@ -564,3 +564,13 @@ def test_blocker_breakdown_query_groups_on_same_postgres_expression(): assert "GROUP BY blocked_products.field" in sql assert "GROUP BY coalesce" not in sql + + +def test_remaining_technical_enums_have_human_labels(): + from central.app.panel_presentation import health_reason_label + assert blocker_reason_label("INVALID_NCM") == "NCM inválido" + assert blocker_reason_label("LEGACY_NCM") == "NCM legado" + assert blocker_type_label("BLOCKED_MISSING_EVIDENCE") == "Evidência fiscal ausente" + assert health_reason_label("last heartbeat exceeds 300s") == "Sem heartbeat recente" + assert health_reason_label("persistent technical failure") == "Falha técnica persistente" + assert blocker_reason_label("SOMETHING_NEW") == "Something new" # unknown codes degrade readably, never crash diff --git a/central/tests/test_version_reference.py b/central/tests/test_version_reference.py new file mode 100644 index 0000000..7da80a6 --- /dev/null +++ b/central/tests/test_version_reference.py @@ -0,0 +1,63 @@ +"""'Atualização disponível' must compare the Agent with the real default release, semantically.""" +from datetime import datetime, timezone + +from central.app.control_plane import dashboard_summary, list_installations +from central.app.installation_identity import compute_version_drift, outdated_versions, parse_version, reference_agent_version +from central.app.models import AgentRelease +from central.tests.test_control_plane import setup_db +from central.tests.test_operational_view_models import NOW, _installation + + +def _release(session, version, status="PUBLISHED", platform="windows"): + session.add(AgentRelease(version=version, platform=platform, architecture="x64", filename=f"GTIFiscalAgent-Setup-{version}.exe", storage_path=f"{version}.exe", + size_bytes=1, sha256=version.ljust(64, "0")[:64], status=status)) + session.flush() + + +def test_versions_compare_semantically_not_as_strings(): + assert compute_version_drift("1.1.7", "1.1.7") == "CURRENT" + assert compute_version_drift("1.1.10", "1.1.9") == "CURRENT" # string compare would say older + assert compute_version_drift("1.1.6", "1.1.7") == "OUTDATED" + assert compute_version_drift("1.2.0", "1.1.7") == "CURRENT" # ahead of the release is not "outdated" + assert compute_version_drift("1.0.0rc1", "1.0.0") == "OUTDATED" # pre-release sorts below its final + assert compute_version_drift(None, "1.1.7") == "UNKNOWN" + assert parse_version("garbage") is None + + +def test_reference_is_the_highest_published_release_and_ignores_revoked_and_drafts(): + with setup_db()() as session: + _release(session, "1.1.5", "REVOKED") + _release(session, "1.1.6") + _release(session, "1.1.7") + _release(session, "1.9.9", "REVOKED") + _release(session, "2.0.0", "DRAFT") + _release(session, "3.0.0", platform="linux") + assert reference_agent_version(session) == "1.1.7" + + +def test_agent_on_the_default_release_is_current_everywhere_even_with_a_stale_snapshot(): + with setup_db()() as session: + _release(session, "1.1.7") + _release(session, "1.1.6", "REVOKED") + # snapshot was written as OUTDATED by an older heartbeat (compared with a stale reference) + _installation(session, 1, version="1.1.7") + _installation(session, 2, version="1.1.6") + session.commit() + assert reference_agent_version(session) == "1.1.7" + assert outdated_versions(session, "1.1.7") == ["1.1.6"] + rows = {item["agent_version"]: item for item in list_installations(session, now=NOW)["items"]} + assert rows["1.1.7"]["version_drift"] == "CURRENT" and rows["1.1.6"]["version_drift"] == "OUTDATED" + assert [i["agent_version"] for i in list_installations(session, version="outdated", now=NOW)["items"]] == ["1.1.6"] + summary = dashboard_summary(session, now=NOW) + assert [a["agent_version"] for a in summary["outdated_agents"]] == ["1.1.6"] + + +def test_a_newer_published_release_makes_the_previous_one_outdated(): + with setup_db()() as session: + _release(session, "1.1.7") + _installation(session, 1, version="1.1.7") + session.commit() + assert list_installations(session, version="outdated", now=NOW)["total"] == 0 + _release(session, "1.1.8") + session.commit() + assert list_installations(session, version="outdated", now=NOW)["total"] == 1