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 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 %}
|
||||
<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>
|
||||
<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="status-stack"><div class="status-block"><small class="status-label">Saúde técnica</small><span class="badge {{ detail.technical_health|lower }} large">{{ detail.technical_health_label }}</span></div><div class="status-block"><small class="status-label">Status fiscal</small><span class="badge fiscal-{{ detail.fiscal_status|lower }} large">{{ detail.fiscal_status_label }}</span></div></div></div>
|
||||
<section class="cards"><article class="metric"><span>Produtos</span><strong>{{ detail.total_products if detail.total_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Completos</span><strong>{{ detail.fiscally_complete if detail.fiscally_complete is not none else "Não informado" }}</strong></article><article class="metric"><span>Bloqueados com segurança</span><strong>{{ detail.safe_blocked if detail.safe_blocked is not none else "Não informado" }}</strong></article><article class="metric"><span>Falhas técnicas</span><strong>{{ detail.failed_products if detail.failed_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Não classificados</span><strong>{{ detail.unclassified_products if detail.unclassified_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Cobertura fiscal</span>{% if detail.fiscal_completion_percent is none %}<strong>Não informado</strong><small>nenhum snapshot recebido</small>{% else %}<strong>{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ detail.fiscally_complete }} / {{ detail.total_products }} completos</small>{% endif %}</article></section>
|
||||
{% if detail.total_products is none %}<p class="coverage-status">Cobertura ainda não informada: nenhum snapshot recebido deste servidor.</p>{% elif detail.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada para este servidor.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ detail.coverage_overcount_products }} acima do total{% if detail.coverage_invalid_counts %}; existem contagens negativas{% endif %}.</p>{% endif %}
|
||||
<section class="panel ops-panel"><div class="panel-title"><div><h2>Processamento</h2><p class="muted">Controlado pelo Hub — nenhuma configuração local.</p></div><div class="ops-actions">
|
||||
<form method="post" action="/agents/{{ detail.agent_id }}/sync-now" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" {% if not ops.enabled %}disabled title="Processamento pausado"{% endif %}>Sincronizar agora</button></form>
|
||||
<a class="botao" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a>
|
||||
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="secondary">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit">Retomar processamento</button></form>{% endif %}
|
||||
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd><dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?job_id={{ ops.last_job.id }}">{{ ops.last_job.kind }}</a> · {{ ops.last_job.status }}{% else %}nenhum{% endif %}</dd></dl></section>
|
||||
<form method="post" action="/agents/{{ detail.agent_id }}/sync-now" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" {% if not ops.enabled %}disabled title="Processamento pausado: retome para sincronizar"{% else %}title="Verifica apenas as alterações desde a última sincronização"{% endif %}>Sincronizar agora</button></form>
|
||||
<a class="botao secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation" title="Recalcula todos os produtos (pede confirmação)">Reprocessar tudo</a>
|
||||
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="caution" title="Mantém o heartbeat, mas não executa jobs">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" title="Volta a executar jobs">Retomar processamento</button></form>{% endif %}
|
||||
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd><dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></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 "Não informado" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "Não informado" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "Não informado" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "Não informado" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "Não informado" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "Não informado" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version_display }}</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 "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}</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></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "Não informado" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "Não informado" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "Não informado" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "Não informado" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "Não informado" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "Não informado" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version_display }}</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 "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}</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>
|
||||
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers?agent_id={{ detail.agent_id }}">Ver blockers</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_label }} · {{ item.age }}</small></span><em class="badge blocked">{{ item.blocker_type_label }}</em></a>{% else %}<p class="empty-state">Nenhum blocker aberto.</p>{% endfor %}</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Agents | GTI Fiscal{% endblock %}
|
||||
{% block title %}Agents / Servidores | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Clientes e servidores ativos. Use Histórico para consultar instalações inativas ou revogadas.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents/releases" class="secondary">Releases</a></div></div>
|
||||
<section class="panel filter-panel">
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
<script>
|
||||
(function () {
|
||||
var tema = localStorage.getItem("tema");
|
||||
if (tema === "claro" || tema === "escuro") {
|
||||
document.documentElement.setAttribute("data-tema", tema);
|
||||
}
|
||||
// Sem preferência salva o padrão é o tema escuro; a escolha explícita do usuário sempre prevalece.
|
||||
document.documentElement.setAttribute("data-tema", tema === "claro" ? "claro" : "escuro");
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
<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="status-stack"><div class="status-block"><small class="status-label">Tipo</small><span class="badge blocked large">{{ detail.blocker_type_label }}</span></div><div class="status-block"><small class="status-label">Prioridade</small><span class="badge priority-{{ detail.priority|lower }} large">{{ detail.priority_label }}</span></div></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>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>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_label }}</dd><dt>Motivo</dt><dd>{{ detail.reason_label }}</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>
|
||||
{% if resolution_available %}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</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"] %}{% if summary.blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?{{ field_query_base|e }}&field={{ key }}"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}{% endfor %}</div></section>
|
||||
<div class="panel table-wrap blockers-table-wrap"><table class="blockers-table"><caption>Fila de blockers · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Produto</th><th>Campo / tipo</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 data-label="Cliente / servidor"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Instalação não informada" }}</small></td><td data-label="Produto"><a class="row-primary-link" href="{{ item.link }}"><b>{{ item.product_code }}</b><small>{{ item.description or "Sem descrição" }}</small></a></td><td data-label="Campo / tipo">{{ item.field or "Outros" }}<small>{{ item.blocker_type_label }}</small></td><td data-label="Prioridade"><span class="badge priority-{{ item.priority|lower }}">{{ item.priority_label }}</span></td><td data-label="Problema">{{ item.reason_label }}<small>{{ item.age }}</small></td><td data-label="Última ocorrência" title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td data-label="Ação"><a class="secondary" href="{{ item.link }}">Ver detalhe</a></td></tr>{% else %}<tr><td colspan="7" class="empty-state">Nenhum blocker corresponde aos filtros. <a href="/blockers?scope=all&status=ALL">Consultar histórico</a></td></tr>{% endfor %}
|
||||
{% for item in result["items"] %}<tr><td data-label="Cliente / servidor"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Instalação não informada" }}</small></td><td data-label="Produto"><a class="row-primary-link" href="{{ item.link }}"><b>{{ item.product_code }}</b><small>{{ item.description or "Sem descrição" }}</small></a></td><td data-label="Campo / tipo">{{ item.field or "Outros" }}<small>{{ item.blocker_type_label }}</small></td><td data-label="Prioridade"><span class="badge priority-{{ item.priority|lower }}">{{ item.priority_label }}</span></td><td data-label="Problema">{{ item.reason_label }}<small>{{ item.age }}</small></td><td data-label="Última ocorrência" title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td data-label="Ação"><a class="secondary" href="{{ item.link }}">Ver detalhes</a></td></tr>{% else %}<tr><td colspan="7" class="empty-state">Nenhum blocker corresponde aos filtros. <a href="/blockers?scope=all&status=ALL">Consultar histórico</a></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|e }}{% 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|e }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</section>
|
||||
<section class="split overview-split">
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Cobertura dos produtos</h2><a href="/agents?scope=active">Ver instalações</a></div>
|
||||
<div class="panel-title"><h2>Cobertura dos produtos</h2><a href="/agents?scope=active">Ver servidores</a></div>
|
||||
<div class="coverage">
|
||||
{% 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")] %}
|
||||
<a class="coverage-item" href="{{ url|safe }}" aria-label="{{ label }}: {{ summary[key] }}. Abrir listagem"><strong>{{ summary[key] }}</strong><span>{{ label }}</span></a>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="registry-summary"><a href="/blockers?scope=active&status=OPEN">Produtos com blockers abertos: <strong>{{ summary.safe_blocked_products }}</strong></a><span>Blockers abertos: <strong>{{ summary.open_blockers }}</strong></span></div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&status=OPEN">Abrir fila</a></div>
|
||||
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&status=OPEN">Ver blockers</a></div>
|
||||
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
|
||||
{% if summary.open_blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?scope=active&status=OPEN&field={{ key }}" aria-label="{{ key }}: {{ summary.open_blocker_breakdown[key] }} blockers abertos"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>
|
||||
{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}
|
||||
@@ -32,7 +32,7 @@
|
||||
</article>
|
||||
</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?scope=active&status=OPEN">Ver todos</a></div>
|
||||
<div class="panel-title"><div><p class="eyebrow">FILA DE AÇÃO</p><h2>Atenção necessária</h2></div><a href="/blockers?scope=active&status=OPEN">Ver blockers</a></div>
|
||||
{% for item in summary.attention_items %}
|
||||
<a class="list-row attention-row" href="{{ item.link }}"><span><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Servidor sem nome" }} · {{ item.problem }}</small>{% if item.group_count %}<small>{{ item.products_affected }} produtos afetados · {{ item.group_count }} blockers abertos · Ver blockers →</small>{% endif %}</span><em class="badge {{ 'priority-' ~ item.priority|lower if item.priority else item.technical_health|lower }}">{{ item.age }}</em></a>
|
||||
{% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}
|
||||
@@ -41,12 +41,12 @@
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?scope=active&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 class="compact-table"><caption>Falhas recentes (máximo 10)</caption><thead><tr><th>Cliente / servidor</th><th>Tipo / erro</th><th>Falhou</th><th>Ação</th></tr></thead><tbody>
|
||||
{% for job in summary.recent_failed_jobs %}<tr><td><b>{{ job.client or "Cliente não identificado" }}</b><small>{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}</small></td><td>{{ job.kind_label }}<small class="error-text">{{ job.error or "Sem detalhe de erro" }}</small></td><td title="{{ job.finished_at }}">{{ job.finished_display }}<small>{{ job.finished_age }}</small></td><td><a href="/jobs?scope=active&status=FAILED&job_id={{ job.job_id }}">Ver job #{{ job.job_id }}</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}
|
||||
<ul class="failed-jobs" aria-label="Falhas recentes (máximo 10)">
|
||||
{% for job in summary.recent_failed_jobs %}<li class="failed-job"><div class="failed-job-main"><b>{{ job.client or "Cliente não identificado" }}</b><small>{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}</small><span class="failed-job-kind">{{ job.kind_label }}</span><small class="error-text job-error-summary" title="{{ job.error or '' }}">{{ job.error or "Sem detalhe de erro" }}</small></div><div class="failed-job-side"><span title="{{ job.finished_at }}">{{ job.finished_age }}</span><small>{{ job.finished_display }}</small><a href="/jobs?scope=active&status=FAILED&job_id={{ job.job_id }}">Ver detalhes</a></div></li>{% endfor %}
|
||||
</ul>{% 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?scope=active&version=outdated">Ver todos</a></div>
|
||||
<div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?scope=active&version=outdated">Ver servidores</a></div>
|
||||
{% for item in summary.outdated_agents[:5] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small><small>{{ item.agent_version or "Versão não informada" }} → atualização disponível</small></span><em class="badge warning">Desatualizado</em></a>{% else %}<p class="empty-state">Nenhum Agent desatualizado.</p>{% endfor %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</form>{% if status_filter or job_id_filter or scope_filter=="all" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if status_filter %}<span class="filter-chip">Status: {{ {"PENDING":"Pendente","RUNNING":"Executando","COMPLETED":"Concluído","FAILED":"Falhou"}[status_filter] }}</span>{% endif %}{% if job_id_filter %}<span class="filter-chip">Job #{{ job_id_filter }}</span>{% endif %}</p>{% endif %}</section>
|
||||
{% if job_id_filter and result["items"] %}{% set selected = result["items"][0] %}<section class="panel job-detail"><div class="panel-title"><h2>Job #{{ selected.job_id }}</h2><a href="/jobs?scope={{ scope_filter }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Voltar à lista</a></div><p><b>{{ selected.client or "Cliente não identificado" }}</b> · {{ selected.display_name or "Instalação" }} · {{ selected.hostname or "Servidor não informado" }}</p><p>{{ selected.kind_label }} <span class="badge status-{{ selected.status|lower }}">{{ selected.status_label }}</span></p><p><b>Erro completo</b></p><pre class="job-error-full">{{ selected.error or "Sem erro registrado" }}</pre><small>Tipo técnico: {{ selected.kind }} · Status técnico: {{ selected.status }}</small></section>{% endif %}
|
||||
<div class="panel table-wrap jobs-table-wrap"><table class="jobs-table"><caption>Execuções recentes · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Tipo / job</th><th>Status</th><th>Criado</th><th>Finalizado / duração</th><th>Erro / ação</th></tr></thead><tbody>
|
||||
{% for item in result["items"] %}<tr class="{% if item.status=='FAILED' %}job-failed-row{% endif %}"><td data-label="Cliente / servidor"><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small></a></td><td data-label="Tipo / job">{{ item.kind_label }}<small>Job #{{ item.job_id }}</small></td><td data-label="Status"><span class="badge status-{{ item.status|lower }}">{{ item.status_label }}</span></td><td data-label="Criado" title="{{ item.created_at }}">{{ item.created_display }}</td><td data-label="Finalizado / duração" title="{{ item.finished_at }}">{{ item.finished_display }}<small>{{ item.duration }}</small></td><td data-label="Erro / ação" class="{% if item.status=='FAILED' %}error-text{% endif %}"><span class="error-summary">{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}</span><a href="/jobs?scope={{ scope_filter }}&job_id={{ item.job_id }}">Ver job</a></td></tr>{% else %}<tr><td colspan="6" class="empty-state">Nenhum job encontrado. <a href="/jobs?scope=all">Consultar histórico</a></td></tr>{% endfor %}
|
||||
{% for item in result["items"] %}<tr class="{% if item.status=='FAILED' %}job-failed-row{% endif %}"><td data-label="Cliente / servidor"><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small></a></td><td data-label="Tipo / job">{{ item.kind_label }}<small>Job #{{ item.job_id }}</small></td><td data-label="Status"><span class="badge status-{{ item.status|lower }}">{{ item.status_label }}</span></td><td data-label="Criado" title="{{ item.created_at }}">{{ item.created_display }}</td><td data-label="Finalizado / duração" title="{{ item.finished_at }}">{{ item.finished_display }}<small>{{ item.duration }}</small></td><td data-label="Erro / ação" class="{% if item.status=='FAILED' %}error-text{% endif %}"><span class="error-summary">{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}</span><a href="/jobs?scope={{ scope_filter }}&job_id={{ item.job_id }}">Ver detalhes</a></td></tr>{% else %}<tr><td colspan="6" class="empty-state">Nenhum job encontrado. <a href="/jobs?scope=all">Consultar histórico</a></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|e }}{% 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|e }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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("<h2>Contexto da empresa</h2>", 1)[1].split("</article>", 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*</(?:a|button)>", 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 "<title>Agents / Servidores | GTI Fiscal</title>" in client.get("/agents").text
|
||||
assert "<title>Blockers | GTI Fiscal</title>" in client.get("/blockers").text
|
||||
assert "<title>Jobs | GTI Fiscal</title>" 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 '<small class="status-label">Saúde técnica</small>' in page
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user