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:
gti
2026-09-24 14:26:44 -03:00
co-authored by Claude Sonnet 5
parent 9a142975a6
commit c950ffb47f
18 changed files with 247 additions and 52 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ from .blocked_products import list_blockers, resolve_blocker, resolve_product_bl
from .context_keys import validate_entry from .context_keys import validate_entry
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
from .events import record_event 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 .control_plane import list_jobs
from .human_auth import require_human 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.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.coverage_percent = float(coverage.get("coverage_percent", snapshot.coverage_percent) or 0)
snapshot.blockers = coverage.get("blockers", snapshot.blockers) or {} 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) snapshot.agent_health, snapshot.health_reason = compute_agent_health(installation, supplemental=snapshot)
session.add(agent); session.flush() 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}) 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})
+2 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy import select
from .audit import record_audit from .audit import record_audit
from .blocker_resolution import BlockerResolutionUnavailable, resolve_blocker_with_context 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 .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 .human_auth import require_csrf, require_human
from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
@@ -46,7 +47,7 @@ def router(session_factory):
session, _, _ = identity session, _, _ = identity
try: try:
installation, agent, tenant, snapshot = get_installation(session, agent_id) 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() finally: session.close()
@routes.get("/agents/{agent_id}/health") @routes.get("/agents/{agent_id}/health")
+15 -13
View File
@@ -10,9 +10,9 @@ from sqlalchemy import Numeric, and_, case, cast, func, or_, select
from .config import settings from .config import settings
from .events import list_events 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 .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") 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 has_snapshot = snapshot is not None
snapshot = snapshot or AgentSnapshot(installation_id=installation.id) snapshot = snapshot or AgentSnapshot(installation_id=installation.id)
health, reason = compute_agent_health(installation, now=now, supplemental=snapshot) health, reason = compute_agent_health(installation, now=now, supplemental=snapshot)
version_drift = snapshot.version_drift # Always recomputed against the current reference release: the value stored on the snapshot was taken at the
if not version_drift or version_drift == "UNKNOWN": # 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) version_drift = compute_version_drift(agent.agent_version, reference)
fiscal_status = _fiscal_status(snapshot) fiscal_status = _fiscal_status(snapshot)
coverage_partition = _coverage_partition(snapshot) coverage_partition = _coverage_partition(snapshot)
if not has_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): 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) page, page_size = _page(page, page_size)
now = _utc_now(now) 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) 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": if scope == "active":
query = query.where(operational_installation_predicate()) 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") 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) query = _installation_query_filters(query, client=client, version=version, search=search, last_seen_before=last_seen_before)
if version == "outdated": 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) health_expression = _health_expression(now)
if health: if health:
query = query.where(health_expression == 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: else:
ordered = query.order_by(Installation.display_name.asc(), Installation.id.asc()) ordered = query.order_by(Installation.display_name.asc(), Installation.id.asc())
rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all() 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()) count_query = select(func.count()).select_from(query.with_only_columns(Installation.id).order_by(None).subquery())
total = session.scalar(count_query) or 0 total = session.scalar(count_query) or 0
return {"page": page, "page_size": page_size, "items": items, "total": total, "health_filter_applied": health} 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): def dashboard_summary(session, now=None):
now = _utc_now(now) now = _utc_now(now)
reference = reference_agent_version(session)
active = operational_installation_predicate() 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) 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) health_expression = _health_expression(now)
@@ -483,12 +485,12 @@ def dashboard_summary(session, now=None):
def installation_candidates(health): def installation_candidates(health):
query = installation_query.where(health_expression == health).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT) 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")} 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_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")) group_field = func.upper(func.coalesce(BlockedProduct.field, "OTHER"))
fiscal_priority = case( fiscal_priority = case(
@@ -512,13 +514,13 @@ def dashboard_summary(session, now=None):
attention = [] attention = []
for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN"): for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN"):
rank = {"FAILED": 0, "DEGRADED": 1, "OFFLINE": 2, "UNKNOWN": 3}[health] 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: 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"} 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}) 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}) priority = operational_priority({"field": field, "blocker_type": blocker_type, "reason_code": reason})
rank = {"CRITICAL": 3, "HIGH": 4, "NORMAL": 5, "LOW": 6}[priority] 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) entry.update(group_count=count, products_affected=products, field=field, priority=priority)
attention.append((entry, rank, agent_id)) attention.append((entry, rank, agent_id))
for item in outdated_agents: for item in outdated_agents:
+46 -4
View File
@@ -94,11 +94,53 @@ def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental=
return "ONLINE", "recent heartbeat without critical technical failure" 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: if not agent_version:
return "UNKNOWN" 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: def outdated_versions(session, reference: str) -> list[str]:
return hashlib.sha256("|".join(parts).encode()).hexdigest() """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"]
+22 -2
View File
@@ -121,11 +121,31 @@ def job_kind_label(value):
def blocker_type_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): 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): def priority_label(value):
+4 -2
View File
@@ -14,6 +14,8 @@ from . import agent_distribution, client_sync, config, enrollment, sso
from .config import settings from .config import settings
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
from .context_keys import ALLOWED_KEYS, ENUMS 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 .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 .human_auth import create_session, current_session, require_csrf, verify_password
from .panel_presentation import format_ui_datetime 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() 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) if row is None: return HTMLResponse("Agent not found", status_code=404)
installation, agent, tenant, snapshot = row 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) 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)) 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 has_more = len(events) > 25
@@ -295,7 +297,7 @@ def router(session_factory):
next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds) next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds)
return {"enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60, 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_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): async def _installation_action(request, agent_id, action):
session, auth_data, redirect = page_auth(request) session, auth_data, redirect = page_auth(request)
+22
View File
@@ -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: 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, .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; } } @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); }
+1 -1
View File
@@ -6,7 +6,7 @@
function temaAtual() { function temaAtual() {
var salvo = localStorage.getItem("tema"); var salvo = localStorage.getItem("tema");
if (salvo === "claro" || salvo === "escuro") return salvo; 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 () { botao.addEventListener("click", function () {
+8 -8
View File
@@ -1,17 +1,17 @@
{% extends "base.html" %} {% 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 %} {% 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> <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 %} {% 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"> <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> <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" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a> <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="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 %} {% 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?job_id={{ ops.last_job.id }}">{{ ops.last_job.kind }}</a> · {{ ops.last_job.status }}{% else %}nenhum{% endif %}</dd></dl></section> </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&amp;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> <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 }}&amp;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> <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 }}&amp;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" 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 %} {% endblock %}
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Agents | GTI Fiscal{% endblock %} {% block title %}Agents / Servidores | GTI Fiscal{% endblock %}
{% block content %} {% 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> <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"> <section class="panel filter-panel">
+2 -3
View File
@@ -11,9 +11,8 @@
<script> <script>
(function () { (function () {
var tema = localStorage.getItem("tema"); var tema = localStorage.getItem("tema");
if (tema === "claro" || tema === "escuro") { // 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); document.documentElement.setAttribute("data-tema", tema === "claro" ? "claro" : "escuro");
}
})(); })();
</script> </script>
</head> </head>
+2 -2
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %} {% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %}
{% block content %} {% 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 %} {% if error %}<div class="panel"><p class="error">{{ error }}</p></div>{% endif %}
<section class="split"> <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> <article class="panel"><h2>Contexto atual</h2><p class="muted">Valor atual por chave persistida para esta empresa.</p>{% for entry in context_entries %}<div class="list-row"><span><b>{{ entry.key }}</b><small>{{ entry.scope }} / {{ entry.scope_key or "empresa" }}</small></span><em>{{ entry.value }}</em></div>{% else %}<p class="muted">Nenhum valor definido.</p>{% endfor %}</article>
</section> </section>
{% if resolution_available %} {% if resolution_available %}
+1 -1
View File
@@ -28,7 +28,7 @@
</section> </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 }}&amp;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> <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 }}&amp;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> <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&amp;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&amp;status=ALL">Consultar histórico</a></td></tr>{% endfor %}
</tbody></table></div> </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 %}&amp;{{ 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 %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav> <nav class="pagination" aria-label="Paginação de blockers">{% if result.page > 1 %}<a href="/blockers?page={{ result.page - 1 }}{% if query_base %}&amp;{{ 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 %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %} {% endblock %}
+7 -7
View File
@@ -14,7 +14,7 @@
</section> </section>
<section class="split overview-split"> <section class="split overview-split">
<article class="panel"> <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"> <div class="coverage">
{% for key,label,url in [("total_products","Produtos avaliados","/agents?scope=active"),("fiscally_complete","Completos","/agents?scope=active&amp;sort=worst_coverage"),("safe_blocked","Bloqueados com segurança","/blockers?scope=active&amp;status=OPEN"),("failed_products","Falhas técnicas","/agents?scope=active&amp;health=FAILED"),("unclassified_products","Não classificados","/agents?scope=active&amp;sort=worst_coverage")] %} {% for key,label,url in [("total_products","Produtos avaliados","/agents?scope=active"),("fiscally_complete","Completos","/agents?scope=active&amp;sort=worst_coverage"),("safe_blocked","Bloqueados com segurança","/blockers?scope=active&amp;status=OPEN"),("failed_products","Falhas técnicas","/agents?scope=active&amp;health=FAILED"),("unclassified_products","Não classificados","/agents?scope=active&amp;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> <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&amp;status=OPEN">Produtos com blockers abertos: <strong>{{ summary.safe_blocked_products }}</strong></a><span>Blockers abertos: <strong>{{ summary.open_blockers }}</strong></span></div> <div class="registry-summary"><a href="/blockers?scope=active&amp;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>
<article class="panel"> <article class="panel">
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&amp;status=OPEN">Abrir fila</a></div> <div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&amp;status=OPEN">Ver blockers</a></div>
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %} {% 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&amp;status=OPEN&amp;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> {% if summary.open_blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?scope=active&amp;status=OPEN&amp;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 %} {% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}
@@ -32,7 +32,7 @@
</article> </article>
</section> </section>
<section class="panel attention"> <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&amp;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&amp;status=OPEN">Ver blockers</a></div>
{% for item in summary.attention_items %} {% 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> <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 %} {% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}
@@ -41,12 +41,12 @@
<article class="panel"> <article class="panel">
<div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?scope=active&amp;status=FAILED">Ver jobs</a></div> <div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?scope=active&amp;status=FAILED">Ver jobs</a></div>
{% if summary.failed_jobs %}<p class="metric-inline"><strong>{{ summary.failed_jobs }}</strong> jobs aguardam investigação.</p> {% 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> <ul class="failed-jobs" aria-label="Falhas recentes (máximo 10)">
{% 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&amp;status=FAILED&amp;job_id={{ job.job_id }}">Ver job #{{ job.job_id }}</a></td></tr>{% endfor %} {% 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&amp;status=FAILED&amp;job_id={{ job.job_id }}">Ver detalhes</a></div></li>{% endfor %}
</tbody></table></div>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %} </ul>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}
</article> </article>
<article class="panel"> <article class="panel">
<div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?scope=active&amp;version=outdated">Ver todos</a></div> <div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?scope=active&amp;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 %} {% 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> </article>
</section> </section>
+1 -1
View File
@@ -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> </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 %}&amp;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 %} {% 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 %}&amp;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> <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 }}&amp;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 }}&amp;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> </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 %}&amp;{{ 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 %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav> <nav class="pagination" aria-label="Paginação de jobs">{% if result.page > 1 %}<a href="/jobs?page={{ result.page - 1 }}{% if query_base %}&amp;{{ 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 %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %} {% endblock %}
+38 -4
View File
@@ -1,3 +1,4 @@
from pathlib import Path
import re import re
from datetime import datetime, timedelta, timezone 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 "Produtos com blockers abertos" in response.text
assert "Blockers abertos" in response.text assert "Blockers abertos" in response.text
assert "Atenção necessária" 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 "Jobs com falha" in response.text
assert "Agents desatualizados" 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) 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("/") response = client.get("/")
assert response.status_code == 200 assert response.status_code == 200
assert "Acme" in response.text and "Acme ERP" in response.text and "srv-01" in response.text 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&amp;status=FAILED&amp;job_id=' in response.text assert 'href="/jobs?scope=active&amp;status=FAILED&amp;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 'title="2026-09-22T18:05:11+00:00"' in response.text
assert ">22/09/2026 15:05<" in response.text assert ">22/09/2026 15:05<" in response.text
with factory() as session: 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 "Desconhecida · Desconhecida" not in response.text
assert "ODS -" 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] 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) 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 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": "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 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 blocked_products.field" in sql
assert "GROUP BY coalesce" not 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
+63
View File
@@ -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