feat: operational panel UI - active-scope dashboard, drill-down cards, scoped Agents/Blockers/Jobs, PT-BR labels, responsive CSS

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 13:38:40 -03:00
co-authored by Claude Sonnet 5
parent bfd4d28aa4
commit 9a142975a6
13 changed files with 725 additions and 88 deletions
+107 -31
View File
@@ -4,19 +4,34 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
from urllib.parse import urlencode
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 .models import Agent, AgentEvent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
from .panel_presentation import format_age, format_duration, format_ui_datetime, health_label, operational_priority
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
HEALTH_STATES = ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN")
BLOCKER_FIELDS = ("CFOP", "ST", "ICMS", "CSOSN", "NCM", "OTHER")
ATTENTION_CANDIDATE_LIMIT = 10
INACTIVE_ENROLLMENT_STATES = ("REVOKED", "FAILED", "EXPIRED")
def operational_installation_predicate():
"""Only installations eligible for current operational indicators.
Legacy Agents without an enrollment remain eligible. Disabling job
processing is not the same as deactivating an installation.
"""
inactive_enrollment = select(AgentEnrollment.id).where(
AgentEnrollment.agent_id == Agent.id,
AgentEnrollment.status.in_(INACTIVE_ENROLLMENT_STATES),
).exists()
return and_(Tenant.active.is_(True), Agent.enabled.is_(True), ~inactive_enrollment)
def _page(page: int, page_size: int):
@@ -167,7 +182,7 @@ def installation_view(installation, agent, tenant, snapshot=None, *, now=None):
"coverage_percent": snapshot.coverage_percent if has_snapshot else None,
"fiscal_completion_percent": _fiscal_completion(snapshot) if has_snapshot else None,
"fiscal_status": fiscal_status if has_snapshot else "UNKNOWN",
"fiscal_status_label": fiscal_status.title() if has_snapshot else "Não informado",
"fiscal_status_label": fiscal_status_label(fiscal_status) if has_snapshot else "Não informado",
"blockers": snapshot.blockers or {},
"last_job_status": snapshot.last_job_status,
"last_job_at": _iso(snapshot.last_job_at),
@@ -186,7 +201,7 @@ def installation_view(installation, agent, tenant, snapshot=None, *, now=None):
def _installation_query_filters(query, *, client=None, version=None, search=None, last_seen_before=None):
if client:
query = query.where(or_(Tenant.name.ilike(f"%{client}%"), Installation.customer_code.ilike(f"%{client}%")))
if version:
if version and version != "outdated":
query = query.where(Agent.agent_version == version)
if search:
query = query.where(or_(Installation.display_name.ilike(f"%{search}%"), Installation.hostname.ilike(f"%{search}%"), Tenant.name.ilike(f"%{search}%")))
@@ -195,11 +210,17 @@ def _installation_query_filters(query, *, client=None, version=None, search=None
return query
def list_installations(session, *, page=1, page_size=50, client=None, health=None, version=None, search=None, last_seen_before=None, sort="display_name", 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)
now = _utc_now(now)
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)
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())
elif scope != "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)
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)))
health_expression = _health_expression(now)
if health:
query = query.where(health_expression == health)
@@ -208,7 +229,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) for i, a, t, s in rows]
items = [{**installation_view(i, a, t, s, now=now), "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}
@@ -223,8 +244,10 @@ def _blocker_item(row, tenant, installation=None, agent=None, *, now=None):
"description": row.description,
"field": row.field,
"blocker_type": row.blocker_type,
"blocker_type_label": blocker_type_label(row.blocker_type),
"reason": row.reason_code,
"reason_code": row.reason_code,
"reason_label": blocker_reason_label(row.reason_code),
"evidence_summary": row.evidence_summary,
"first_seen_at": _iso(row.first_seen_at),
"last_seen_at": _iso(row.last_seen_at),
@@ -235,6 +258,7 @@ def _blocker_item(row, tenant, installation=None, agent=None, *, now=None):
"resolved_at": _iso(row.resolved_at),
"resolution_source": row.resolution_source,
"priority": operational_priority(row),
"priority_label": priority_label(operational_priority(row)),
"installation_id": installation.installation_id if installation else None,
"installation_db_id": installation.id if installation else None,
"display_name": installation.display_name if installation else None,
@@ -260,8 +284,12 @@ def _parse_age(value):
return None
def _blockers_query(session, *, tenant_id=None, agent_id=None, product_code=None, blocker_type=None, field=None, status="OPEN", client=None, installation=None, search=None, age=None):
def _blockers_query(session, *, tenant_id=None, agent_id=None, product_code=None, blocker_type=None, field=None, reason_code=None, status="OPEN", client=None, installation=None, search=None, age=None, scope="active"):
query = select(BlockedProduct, Tenant, Installation, Agent).join(Tenant, Tenant.id == BlockedProduct.tenant_id).outerjoin(Installation, Installation.id == BlockedProduct.installation_id).outerjoin(Agent, Agent.id == Installation.agent_id)
if scope == "active":
query = query.where(operational_installation_predicate(), Installation.id.is_not(None))
elif scope != "all":
raise ValueError("scope must be active or all")
status = (status or "OPEN").upper()
if status == "OPEN":
query = query.where(BlockedProduct.resolved_at.is_(None))
@@ -276,7 +304,9 @@ def _blockers_query(session, *, tenant_id=None, agent_id=None, product_code=None
if blocker_type:
query = query.where(BlockedProduct.blocker_type == blocker_type)
if field:
query = query.where(BlockedProduct.field == field)
query = query.where(func.upper(BlockedProduct.field) == field.upper())
if reason_code:
query = query.where(BlockedProduct.reason_code == reason_code)
if client:
query = query.where(or_(Tenant.name.ilike(f"%{client}%"), BlockedProduct.company.ilike(f"%{client}%")))
if installation:
@@ -290,8 +320,8 @@ def _blockers_query(session, *, tenant_id=None, agent_id=None, product_code=None
return query
def blocker_filter_summary(session, *, tenant_id=None, agent_id=None, product_code=None, blocker_type=None, field=None, status="OPEN", client=None, installation=None, age=None, search=None):
query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, status=status, client=client, installation=installation, search=search, age=age)
def blocker_filter_summary(session, *, tenant_id=None, agent_id=None, product_code=None, blocker_type=None, field=None, reason_code=None, status="OPEN", client=None, installation=None, age=None, search=None, scope="active"):
query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, reason_code=reason_code, status=status, client=client, installation=installation, search=search, age=age, scope=scope)
open_count = func.sum(case((BlockedProduct.resolved_at.is_(None), 1), else_=0))
open_count, = session.execute(query.with_only_columns(open_count).order_by(None)).one()
rows = session.execute(_blocker_breakdown_query(query)).all()
@@ -314,11 +344,11 @@ def _blocker_breakdown_query(query):
return query.with_only_columns(BlockedProduct.field, func.count(BlockedProduct.id)).group_by(BlockedProduct.field).order_by(None)
def list_blockers(session, *, tenant_id=None, agent_id=None, product_code=None, page=1, page_size=50, blocker_type=None, field=None, resolved=False, client=None, installation=None, status="OPEN", age=None, search=None):
def list_blockers(session, *, tenant_id=None, agent_id=None, product_code=None, page=1, page_size=50, blocker_type=None, field=None, reason_code=None, resolved=False, client=None, installation=None, status="OPEN", age=None, search=None, scope="active"):
page, page_size = _page(page, page_size)
if resolved and status == "OPEN":
status = "RESOLVED"
query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, status=status, client=client, installation=installation, search=search, age=age)
query = _blockers_query(session, tenant_id=tenant_id, agent_id=agent_id, product_code=product_code, blocker_type=blocker_type, field=field, reason_code=reason_code, status=status, client=client, installation=installation, search=search, age=age, scope=scope)
ordered = query.order_by(BlockedProduct.last_seen_at.desc(), BlockedProduct.id.desc())
rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all()
items = [_blocker_item(blocker, tenant, installation_row, agent, now=datetime.now(timezone.utc)) for blocker, tenant, installation_row, agent in rows]
@@ -335,7 +365,9 @@ def _job_item(row, agent, installation, tenant, *, now=None):
"display_name": installation.display_name if installation else None,
"hostname": installation.hostname if installation else None,
"kind": row.kind,
"kind_label": job_kind_label(row.kind),
"status": row.status,
"status_label": job_status_label(row.status),
"created_at": _iso(row.created_at),
"started_at": _iso(row.started_at),
"finished_at": _iso(row.finished_at),
@@ -349,9 +381,13 @@ def _job_item(row, agent, installation, tenant, *, now=None):
}
def list_jobs(session, *, agent_id=None, job_id=None, page=1, page_size=50, status=None, kind=None, since=None, until=None, client=None, search=None):
def list_jobs(session, *, agent_id=None, job_id=None, page=1, page_size=50, status=None, kind=None, since=None, until=None, client=None, search=None, scope="active"):
page, page_size = _page(page, page_size)
query = select(Job, Agent, Installation, Tenant).join(Agent, Agent.id == Job.agent_id).join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id)
if scope == "active":
query = query.where(operational_installation_predicate())
elif scope != "all":
raise ValueError("scope must be active or all")
if agent_id is not None:
query = query.where(Job.agent_id == agent_id)
if job_id is not None:
@@ -376,9 +412,26 @@ def list_jobs(session, *, agent_id=None, job_id=None, page=1, page_size=50, stat
return {"page": page, "page_size": page_size, "items": [_job_item(row, agent, installation, tenant) for row, agent, installation, tenant in rows], "total": total}
def jobs_status_summary(session, *, scope="active"):
"""Bounded job counts for the current operational scope."""
query = (select(Job.status, func.count(Job.id)).join(Agent, Agent.id == Job.agent_id)
.join(Installation, Installation.agent_id == Agent.id)
.join(Tenant, Tenant.id == Installation.tenant_id))
if scope == "active":
query = query.where(operational_installation_predicate())
elif scope != "all":
raise ValueError("scope must be active or all")
counts = {status: 0 for status in ("PENDING", "RUNNING", "COMPLETED", "FAILED")}
for status, count in session.execute(query.group_by(Job.status)):
if status in counts:
counts[status] = int(count)
return counts
def dashboard_summary(session, now=None):
now = _utc_now(now)
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)
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)
health_rows = session.execute(
select(health_expression.label("health"), func.count(Installation.id))
@@ -386,6 +439,7 @@ def dashboard_summary(session, now=None):
.join(Agent, Agent.id == Installation.agent_id)
.join(Tenant, Tenant.id == Installation.tenant_id)
.outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id)
.where(active)
.group_by(health_expression)
).all()
counts = {state: 0 for state in HEALTH_STATES}
@@ -407,20 +461,23 @@ def dashboard_summary(session, now=None):
func.coalesce(func.sum(case((and_(~snapshot_has_negative, snapshot_explicit <= snapshot_total), snapshot_total - snapshot_explicit), else_=0)), 0),
func.coalesce(func.sum(case((and_(~snapshot_has_negative, snapshot_explicit > snapshot_total), snapshot_explicit - snapshot_total), else_=0)), 0),
func.coalesce(func.sum(case((snapshot_has_negative, 1), else_=0)), 0),
).select_from(Installation).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id)
).select_from(Installation).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(active)
).one()
totals = dict(zip(("total_products", "fiscally_complete", "safe_blocked", "failed_products", "unclassified_products", "coverage_overcount_products", "coverage_invalid_snapshots"), (int(value or 0) for value in total_row)))
totals["coverage_invariant_pass"] = totals["coverage_overcount_products"] == 0 and totals["coverage_invalid_snapshots"] == 0
open_filter = BlockedProduct.resolved_at.is_(None)
open_blockers = session.scalar(select(func.count(BlockedProduct.id)).where(open_filter)) or 0
active_blockers = (select(BlockedProduct).join(Installation, Installation.id == BlockedProduct.installation_id)
.join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == BlockedProduct.tenant_id)
.where(open_filter, active))
open_blockers = session.scalar(active_blockers.with_only_columns(func.count(BlockedProduct.id)).order_by(None)) or 0
breakdown_expression = func.coalesce(BlockedProduct.field, "OTHER")
breakdown_rows = session.execute(select(breakdown_expression, func.count(BlockedProduct.id)).where(open_filter).group_by(breakdown_expression)).all()
breakdown_rows = session.execute(active_blockers.with_only_columns(breakdown_expression, func.count(BlockedProduct.id)).group_by(breakdown_expression).order_by(None)).all()
open_blocker_breakdown = {field: 0 for field in BLOCKER_FIELDS}
for field, count in breakdown_rows:
key = str(field or "").upper()
open_blocker_breakdown[key if key in open_blocker_breakdown else "OTHER"] += int(count)
product_pairs = select(BlockedProduct.tenant_id, BlockedProduct.product_code).where(open_filter).distinct().subquery()
product_pairs = active_blockers.with_only_columns(BlockedProduct.tenant_id, BlockedProduct.product_code).distinct().subquery()
safe_blocked_products = session.scalar(select(func.count()).select_from(product_pairs)) or 0
blocker_counts = {field: count for field, count in open_blocker_breakdown.items() if count}
@@ -433,28 +490,47 @@ def dashboard_summary(session, now=None):
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()]
blocker_query = select(BlockedProduct, Tenant, Installation, Agent, AgentSnapshot).join(Tenant, Tenant.id == BlockedProduct.tenant_id).outerjoin(Installation, Installation.id == BlockedProduct.installation_id).outerjoin(Agent, Agent.id == Installation.agent_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(open_filter, func.upper(BlockedProduct.field).in_(BLOCKER_FIELDS[:-1])).order_by(BlockedProduct.last_seen_at.desc(), BlockedProduct.id.desc()).limit(ATTENTION_CANDIDATE_LIMIT)
blocker_candidates = session.execute(blocker_query).all()
group_field = func.upper(func.coalesce(BlockedProduct.field, "OTHER"))
fiscal_priority = case(
(func.upper(BlockedProduct.blocker_type).in_(("TECHNICAL_FAILURE", "CONNECTION_ERROR", "AGENT_OFFLINE", "JOB_FAILED")), 0),
(func.upper(BlockedProduct.blocker_type).in_(("BLOCKED_MISSING_EVIDENCE", "MISSING_EVIDENCE")), 1),
(group_field.in_(BLOCKER_FIELDS[:-1]), 2),
else_=3,
)
blocker_groups = session.execute(
active_blockers.with_only_columns(
Tenant.name, Installation.display_name, Installation.hostname, Agent.id,
group_field, BlockedProduct.blocker_type, BlockedProduct.reason_code,
func.count(BlockedProduct.id), func.count(func.distinct(BlockedProduct.product_code)),
func.max(BlockedProduct.last_seen_at),
).group_by(Tenant.name, Installation.id, Installation.display_name, Installation.hostname,
Agent.id, group_field, BlockedProduct.blocker_type, BlockedProduct.reason_code)
.order_by(fiscal_priority.asc(), func.max(BlockedProduct.last_seen_at).desc(), Agent.id.asc())
.limit(ATTENTION_CANDIDATE_LIMIT)
).all()
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])
for blocker, tenant, installation, agent, snapshot in blocker_candidates:
if installation and agent:
item = installation_view(installation, agent, tenant, snapshot, now=now)
else:
item = {"client": tenant.name, "display_name": None, "hostname": None, "agent_id": None, "technical_health": "UNKNOWN", "health_reason": "tenant-level blocker", "last_seen_at": None, "last_job_at": None}
attention.append((_attention_item(item, f"{blocker.field or blocker.blocker_type}: {blocker.reason_code}", blocker.last_seen_at, rank=4, link=f"/blockers/{blocker.id}", now=now), 4, blocker.id))
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.update(group_count=count, products_affected=products, field=field, priority=priority)
attention.append((entry, rank, agent_id))
for item in outdated_agents:
attention.append((_attention_item(item, "Versão do agent desatualizada", item.get("last_seen_at"), rank=5, now=now), 5, item["agent_id"]))
attention.append((_attention_item(item, "Versão do agent desatualizada", item.get("last_seen_at"), rank=7, now=now), 7, item["agent_id"]))
attention.sort(key=lambda entry: (entry[1], -(entry[0]["timestamp"] is not None), entry[2]))
failed_jobs = session.scalar(select(func.count(Job.id)).where(Job.status == "FAILED")) or 0
active_jobs = select(Job).join(Agent, Agent.id == Job.agent_id).join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id).where(active)
failed_jobs = session.scalar(active_jobs.with_only_columns(func.count(Job.id)).where(Job.status == "FAILED").order_by(None)) or 0
failed_job_rows = session.execute(
select(Job, Agent, Installation, Tenant).join(Agent, Agent.id == Job.agent_id)
.join(Installation, Installation.agent_id == Agent.id).join(Tenant, Tenant.id == Installation.tenant_id)
.where(Job.status == "FAILED").order_by(Job.created_at.desc(), Job.id.desc()).limit(10)
.where(Job.status == "FAILED", active).order_by(Job.created_at.desc(), Job.id.desc()).limit(10)
).all()
recent_failed_jobs = []
for job, agent, installation, tenant in failed_job_rows:
@@ -482,7 +558,7 @@ def dashboard_summary(session, now=None):
def blocker_detail(session, blocker_id):
row = session.execute(_blockers_query(session, status="ALL").where(BlockedProduct.id == blocker_id)).first()
row = session.execute(_blockers_query(session, status="ALL", scope="all").where(BlockedProduct.id == blocker_id)).first()
if not row:
return None
blocker, tenant, installation, agent = row
+31
View File
@@ -99,3 +99,34 @@ def health_label(value) -> str:
return "-"
text = str(value)
return _HEALTH_LABELS.get(text.upper(), text)
def _human_label(value, labels):
if not value:
return "Não informado"
text = str(value).upper()
return labels.get(text, str(value).replace("_", " ").lower().capitalize())
def fiscal_status_label(value):
return _human_label(value, {"BLOCKED": "Bloqueado", "COMPLETE": "Completo", "INCOMPLETE": "Incompleto", "UNKNOWN": "Não informado"})
def job_status_label(value):
return _human_label(value, {"PENDING": "Pendente", "RUNNING": "Executando", "COMPLETED": "Concluído", "FAILED": "Falhou"})
def job_kind_label(value):
return _human_label(value, {"SYNC_INCREMENTAL": "Sincronização incremental", "INITIAL_SYNC": "Sincronização inicial", "REPROCESS_ALL": "Reprocessar tudo", "REPROCESS_PRODUCTS": "Reprocessar produtos"})
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"})
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"})
def priority_label(value):
return _human_label(value, {"CRITICAL": "Crítica", "HIGH": "Alta", "NORMAL": "Média", "MEDIUM": "Média", "LOW": "Baixa"})
+20 -10
View File
@@ -14,7 +14,7 @@ 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 .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, 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 .panel_presentation import format_ui_datetime
from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
@@ -162,11 +162,15 @@ def router(session_factory):
finally: session.close()
@routes.get("/agents", response_class=HTMLResponse)
def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None, sort: str = "display_name"):
def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None, sort: str = "display_name", scope: str = "active", version: str | None = None):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "query_base": query_base(search=search, health=health, sort=sort)})
scope = scope if scope in ("active", "all") else "active"
health = health if health in ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN") else None
sort = sort if sort in ("display_name", "worst_coverage") else "display_name"
version = version if version == "outdated" else None
return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort, scope=scope, version=version), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "scope_filter": scope, "version_filter": version or "", "query_base": query_base(search=search, health=health, sort=sort, scope=scope, version=version)})
finally: session.close()
# --- Static /agents/* routes MUST be declared before /agents/{agent_id}:
@@ -278,8 +282,8 @@ def router(session_factory):
events = events[:25]
for event in events:
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25)
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent)})
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25, scope="all")
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent)})
finally: session.close()
def _ops_view(session, installation, agent):
@@ -463,14 +467,16 @@ def router(session_factory):
finally: session.close()
@routes.get("/blockers", response_class=HTMLResponse)
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, reason_code: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None, scope: str = "active"):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
filters = {"agent_id": agent_id, "product_code": product_code, "client": client, "installation": installation, "field": field, "blocker_type": blocker_type, "status": status, "age": age, "search": search}
scope = scope if scope in ("active", "all") else "active"
status = status if status in ("OPEN", "RESOLVED", "ALL") else "OPEN"
filters = {"agent_id": agent_id, "product_code": product_code, "client": client, "installation": installation, "field": field, "blocker_type": blocker_type, "reason_code": reason_code, "status": status, "age": age, "search": search, "scope": scope}
result = list_blockers(session, page=page, page_size=50, **filters)
summary = blocker_filter_summary(session, **filters)
return render(request, "blockers.html", {"result": result, "summary": summary, "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "client_filter": client or "", "installation_filter": installation or "", "field_filter": field or "", "blocker_type_filter": blocker_type or "", "status_filter": status or "OPEN", "age_filter": age or "", "search": search or "", "query_base": query_base(**filters)})
return render(request, "blockers.html", {"result": result, "summary": summary, "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "client_filter": client or "", "installation_filter": installation or "", "field_filter": field or "", "blocker_type_filter": blocker_type or "", "reason_code_filter": reason_code or "", "status_filter": status, "scope_filter": scope, "age_filter": age or "", "search": search or "", "query_base": query_base(**filters), "field_query_base": query_base(**{key: value for key, value in filters.items() if key != "field"})})
finally: session.close()
@routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
@@ -516,10 +522,14 @@ def router(session_factory):
session.close()
@routes.get("/jobs", response_class=HTMLResponse)
def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | None = None, job_id: int | None = None):
def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | None = None, job_id: int | None = None, scope: str = "active"):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try: return render(request, "jobs.html", {"result": list_jobs(session, page=page, page_size=50, status=status, search=search, kind=kind, job_id=job_id), "auth": auth_data, "status_filter": status or "", "search": search or "", "kind_filter": kind or "", "query_base": query_base(status=status, search=search, kind=kind, job_id=job_id)})
try:
scope = scope if scope in ("active", "all") else "active"
status = status if status in ("PENDING", "RUNNING", "COMPLETED", "FAILED") else None
result = list_jobs(session, page=page, page_size=50, status=status, search=search, kind=kind, job_id=job_id, scope=scope)
return render(request, "jobs.html", {"result": result, "status_counts": jobs_status_summary(session, scope=scope), "auth": auth_data, "status_filter": status or "", "scope_filter": scope, "search": search or "", "kind_filter": kind or "", "job_id_filter": job_id, "query_base": query_base(status=status, search=search, kind=kind, job_id=job_id, scope=scope)})
finally: session.close()
return routes
+60
View File
@@ -148,3 +148,63 @@ td a { text-decoration: none; }
.cards, .cards.compact, .split, .bar-grid { grid-template-columns: 1fr; }
.filters label, .filters input, .filters select, .filters button { width: 100%; }
}
/* ---------- painel operacional: drill-down, filtros ativos, tabelas densas ---------- */
a.metric-link { display: block; color: inherit; text-decoration: none; transition: border-color .15s, transform .15s; }
a.metric-link:hover { border-color: var(--cor-acento); transform: translateY(-1px); }
a.metric-link small { color: var(--cor-acento-texto); margin-top: 6px; font-weight: 600; }
a.metric-link:focus-visible, .coverage-item:focus-visible, .bar-link:focus-visible, .row-primary-link:focus-visible, .list-row:focus-visible,
.filter-clear:focus-visible, .navigable-row:focus-visible { outline: 3px solid var(--cor-acento); outline-offset: 2px; }
.coverage-item { display: block; color: inherit; text-decoration: none; border-left: 3px solid var(--cor-acento); padding-left: 12px; border-radius: 4px; }
.coverage-item:hover { background: var(--cor-superficie-alt); }
.coverage-item strong { display: block; font-size: 1.4rem; }
.coverage-item span { color: var(--cor-texto-secundario); font-size: 0.78rem; }
.coverage-status.error { color: var(--cor-perigo); }
.registry-summary { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 14px; font-size: 0.85rem; color: var(--cor-texto-secundario); }
a.bar-link { color: inherit; text-decoration: none; }
a.bar-link:hover { background: var(--cor-superficie-alt); }
.bar-row.muted { opacity: .65; }
.attention-row b, .list-row b { display: block; }
.attention-row { border-left: 3px solid var(--cor-aviso); padding-left: 12px; }
.filter-panel { padding: 14px 20px; }
.active-filters { margin: 12px 0 0; font-size: 0.82rem; color: var(--cor-texto-secundario); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.filter-chip { display: inline-flex; background: var(--cor-acento-suave); color: var(--cor-acento-texto); border-radius: 999px; padding: 2px 10px; font-weight: 600; }
.filter-clear { align-self: end; font-size: 0.85rem; }
.filter-search { flex: 1 1 260px; }
.filter-search input { width: 100%; }
.agents-table-wrap, .table-wrap { overflow-x: auto; }
.agents-table { width: 100%; table-layout: auto; }
.agents-table td small, .agents-table th small { display: block; }
.agents-table td { vertical-align: top; }
.navigable-row { cursor: pointer; }
.navigable-row:hover { background: var(--cor-superficie-alt); }
.historical-row { opacity: .7; }
.row-primary-link { color: inherit; text-decoration: none; display: block; }
.row-primary-link b { color: var(--cor-acento-texto); }
.warning-text { color: var(--cor-aviso); font-weight: 600; }
.compact-table td, .compact-table th { padding: 8px 10px; }
.job-error-summary { max-width: 46ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 1100px) { .agents-table th:nth-child(5), .agents-table td:nth-child(5) { display: none; } }
@media (max-width: 640px) {
.agents-table thead { position: absolute; left: -9999px; }
.agents-table, .agents-table tbody, .agents-table tr, .agents-table td { display: block; width: 100%; }
.agents-table tr { border: 1px solid var(--cor-borda); border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; }
.agents-table td { display: flex; justify-content: space-between; gap: 12px; border: 0; padding: 4px 0; }
.agents-table td::before { content: attr(data-label); color: var(--cor-texto-secundario); font-size: 0.75rem; font-weight: 600; }
.agents-table td:nth-child(5) { display: flex; }
.agents-table td:first-child { display: block; }
.agents-table td:first-child::before { content: none; }
.coverage { grid-template-columns: repeat(2, 1fr); }
}
@media (prefers-reduced-motion: reduce) { a.metric-link { transition: none; } a.metric-link:hover { transform: none; } }
.job-failed-row td:first-child { border-left: 3px solid var(--cor-perigo); }
.job-failed-row .badge { font-weight: 700; }
/* grid children must be allowed to shrink, otherwise a wide table/link inside a panel widens the whole page */
.split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.split > *, .cards > *, .coverage > * { min-width: 0; }
.list-row > span { min-width: 0; overflow-wrap: anywhere; }
code { overflow-wrap: anywhere; }
@media (max-width: 900px) { .split { grid-template-columns: 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 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .cards .metric { padding: 12px; } .cards .metric strong { font-size: 1.5rem; } }
+7
View File
@@ -1,3 +1,10 @@
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.classList.add("js");
// Whole-row navigation for dense tables; the primary cell keeps a native link (keyboard/screen readers).
document.querySelectorAll("tr.navigable-row[data-href]").forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("a, button, input, select, textarea, label")) return;
window.location.href = row.dataset.href;
});
});
});
+27 -4
View File
@@ -1,8 +1,31 @@
{% extends "base.html" %}
{% block title %}Agents | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><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"><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
<div class="panel table-wrap"><table><caption>Instalações monitoradas</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Saúde técnica</th><th>Status fiscal</th><th>Cobertura fiscal</th><th>Produtos</th><th>Versão</th><th>Último heartbeat</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b></a></td><td>{{ item.display_name or "Não informado" }}</td><td>{{ item.hostname or "Não informado" }}</td><td><span class="badge {{ item.technical_health|lower }}">Saúde técnica: {{ item.technical_health_label }}</span></td><td><span class="badge fiscal-{{ item.fiscal_status|lower }}">Status fiscal: {{ item.fiscal_status_label }}</span>{% if item.total_products is none %}<small>Cobertura ainda não informada (nenhum snapshot recebido)</small>{% else %}<small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados</small>{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}</small>{% endif %}{% endif %}</td><td>{% if item.fiscal_completion_percent is none %}<strong>–</strong><small>Não informado</small>{% else %}<strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ item.fiscally_complete }} / {{ item.total_products }} completos</small>{% endif %}</td><td>{% if item.total_products is none %}Não informado{% else %}{{ item.total_products }}<small>{{ item.failed_products }} falhas técnicas</small>{% endif %}</td><td>{{ item.agent_version_display }}</td><td title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.last_seen_age }}</small></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhuma instalação encontrada.</td></tr>{% endfor %}</tbody></table></div>
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
<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">
<form class="filters agents-filters" method="get">
{% if version_filter %}<input type="hidden" name="version" value="{{ version_filter }}">{% endif %}
<label class="filter-search">Cliente, instalação ou hostname<input name="search" type="search" value="{{ search }}" placeholder="Buscar cliente ou servidor"></label>
<label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value,label in [("ONLINE","Online"),("DEGRADED","Atenção"),("OFFLINE","Offline"),("FAILED","Falhou"),("UNKNOWN","Não informado")] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
<label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Cliente / instalação</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label>
<button class="secondary" type="submit">Filtrar</button>
{% if search or health_filter or version_filter or scope_filter=="all" or sort!="display_name" %}<a class="secondary filter-clear" href="/agents?scope=active">Limpar filtros</a>{% endif %}
</form>
{% if health_filter or version_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 health_filter %}<span class="filter-chip">Saúde: {{ {"ONLINE":"Online","DEGRADED":"Atenção","OFFLINE":"Offline","FAILED":"Falhou","UNKNOWN":"Não informado"}[health_filter] }}</span>{% endif %}{% if version_filter %}<span class="filter-chip">Agents desatualizados</span>{% endif %}</p>{% endif %}
</section>
<div class="panel table-wrap agents-table-wrap"><table class="agents-table"><caption>Instalações monitoradas · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Saúde</th><th>Status fiscal</th><th>Cobertura</th><th>Produtos</th><th>Versão</th><th>Último contato</th></tr></thead><tbody>
{% for item in result["items"] %}
<tr class="navigable-row{% if not item.operational_active %} historical-row{% endif %}" data-href="/agents/{{ item.agent_id }}">
<td data-label="Cliente / servidor"><a class="row-primary-link" href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Servidor não informado" }}</small></a>{% if item.display_name and item.hostname and item.display_name!=item.hostname %}<small>{{ item.display_name }}</small>{% endif %}{% if not item.operational_active %}<span class="badge unknown">Histórico / inativo</span>{% endif %}</td>
<td data-label="Saúde"><span class="badge {{ item.technical_health|lower }}">{{ item.technical_health_label }}</span></td>
<td data-label="Status fiscal"><span class="badge fiscal-{{ item.fiscal_status|lower }}">{{ item.fiscal_status_label }}</span>{% if item.total_products is not none %}<small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados{% if item.failed_products %} · {{ item.failed_products }} falhas{% endif %}</small>{% endif %}{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência no snapshot</small>{% endif %}</td>
<td data-label="Cobertura">{% if item.fiscal_completion_percent is none %}Não informada{% else %}<strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong>{% endif %}</td>
<td data-label="Produtos">{{ item.total_products if item.total_products is not none else "Não informado" }}</td>
<td data-label="Versão">{{ item.agent_version or "Não informada" }}{% if item.version_drift=="OUTDATED" %}<small class="warning-text">Atualização disponível</small>{% endif %}</td>
<td data-label="Último contato" title="{{ item.last_seen_at }}">{{ item.last_seen_age }}<small>{{ item.last_seen_display }}</small></td>
</tr>
{% else %}<tr><td colspan="7" class="empty-state">Nenhuma instalação encontrada para os filtros atuais. <a href="/agents?scope=all">Consultar histórico</a></td></tr>{% endfor %}
</tbody></table></div>
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %}
+30 -6
View File
@@ -1,10 +1,34 @@
{% extends "base.html" %}
{% block title %}Blockers | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers globais</h1><p class="muted">Problemas fiscais separados de falhas técnicas.</p></div></div>
<section class="cards compact"><article class="metric"><span>Blockers abertos</span><strong>{{ summary.open_blockers }}</strong></article><article class="metric"><span>Produtos afetados</span><strong>{{ summary.affected_products }}</strong></article><article class="metric"><span>Clientes afetados</span><strong>{{ summary.affected_clients }}</strong></article><article class="metric"><span>Resultado filtrado</span><strong>{{ result.total }}</strong></article></section>
<section class="panel"><form class="filters blocker-filters" method="get">{% if request.query_params.get('agent_id') %}<input type="hidden" name="agent_id" value="{{ request.query_params.get('agent_id') }}">{% endif %}{% if request.query_params.get('product_code') %}<input type="hidden" name="product_code" value="{{ request.query_params.get('product_code') }}">{% endif %}<label>Cliente<input name="client" value="{{ client_filter }}"></label><label>Instalação / Agent / Servidor<input name="installation" value="{{ installation_filter }}"></label><label>Campo<select name="field"><option value="">Todos</option>{% for value in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}"></label><label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label><label>Idade<input name="age" value="{{ age_filter }}" placeholder="ex.: 1d"></label><label>Produto ou busca<input name="search" value="{{ search }}"></label><button class="secondary" type="submit">Filtrar blockers</button></form></section>
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers filtrados</h2></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b></div>{% endfor %}</div></section>
<div class="panel table-wrap"><table><caption>Fila de blockers</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Produto</th><th>Campo</th><th>Tipo de blocker</th><th>Prioridade</th><th>Problema</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or "Cliente não identificado" }}</td><td>{{ item.display_name or "-" }}<small>{{ item.hostname or "hostname indisponível" }}</small></td><td><b>{{ item.product_code }}</b><small>{{ item.description or "" }}</small></td><td>{{ item.field or "OTHER" }}</td><td><span class="badge blocked">Fiscal: {{ item.blocker_type }}</span></td><td><span class="badge priority-{{ item.priority|lower }}">Prioridade: {{ item.priority }}</span></td><td>{{ item.reason }}<small>{{ item.age }}</small></td><td title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td><a class="secondary" href="{{ item.link }}">Ver detalhe</a></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhum blocker corresponde aos filtros.</td></tr>{% endfor %}</tbody></table></div>
<nav class="pagination" aria-label="Paginação de blockers">{% if result.page > 1 %}<a href="/blockers?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} blockers</span>{% if result.page * result.page_size < result.total %}<a href="/blockers?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers</h1><p class="muted">Problemas fiscais da operação ativa; histórico e resolvidos disponíveis no filtro.</p></div></div>
<section class="cards compact" aria-label="Indicadores de blockers">
<a class="metric metric-link" href="/blockers?scope={{ scope_filter }}&amp;status=OPEN"><span>Blockers abertos</span><strong>{{ summary.open_blockers }}</strong><small>Ver abertos →</small></a>
<div class="metric"><span>Produtos afetados</span><strong>{{ summary.affected_products }}</strong></div>
<div class="metric"><span>Clientes afetados</span><strong>{{ summary.affected_clients }}</strong></div>
<div class="metric{% if field_filter or client_filter or installation_filter or blocker_type_filter or reason_code_filter or search or age_filter or scope_filter=='all' or status_filter!='OPEN' %} metric-selected{% endif %}"><span>Resultado filtrado</span><strong>{{ result.total }}</strong></div>
</section>
<section class="panel filter-panel">
<form class="filters blocker-filters" method="get">
{% if request.query_params.get('agent_id') %}<input type="hidden" name="agent_id" value="{{ request.query_params.get('agent_id') }}">{% endif %}
{% if request.query_params.get('product_code') %}<input type="hidden" name="product_code" value="{{ request.query_params.get('product_code') }}">{% endif %}
{% if reason_code_filter %}<input type="hidden" name="reason_code" value="{{ reason_code_filter }}">{% endif %}
<label>Cliente<input name="client" value="{{ client_filter }}"></label>
<label>Instalação / servidor<input name="installation" value="{{ installation_filter }}"></label>
<label>Campo<select name="field"><option value="">Todos</option>{% for value in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value if value!="OTHER" else "Outros" }}</option>{% endfor %}</select></label>
<label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}" placeholder="Tipo técnico ou nome"></label>
<label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label>
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
<label>Idade<input name="age" value="{{ age_filter }}" placeholder="ex.: 1d"></label>
<label class="filter-search">Produto ou busca<input name="search" value="{{ search }}" type="search"></label>
<button class="secondary" type="submit">Filtrar blockers</button>
{% if field_filter or client_filter or installation_filter or blocker_type_filter or reason_code_filter or search or age_filter or scope_filter=="all" or status_filter!="OPEN" or request.query_params.get('agent_id') or request.query_params.get('product_code') %}<a class="secondary filter-clear" href="/blockers?scope=active">Limpar filtros</a>{% endif %}
</form>
{% if field_filter or reason_code_filter or scope_filter=="all" or status_filter!="OPEN" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if field_filter %}<span class="filter-chip">Campo: {{ field_filter }}</span>{% endif %}{% if reason_code_filter %}<span class="filter-chip">Motivo: {{ reason_code_filter }}</span>{% endif %}{% if status_filter!="OPEN" %}<span class="filter-chip">Status: {{ "Resolvidos" if status_filter=="RESOLVED" else "Todos" }}</span>{% endif %}</p>{% endif %}
</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>
{% 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 %}
</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>
{% endblock %}
+46 -10
View File
@@ -1,17 +1,53 @@
{% extends "base.html" %}
{% block title %}Resumo | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1><p class="muted">Fila operacional baseada no último snapshot recebido.</p></div><span class="live-dot">Atualizado pelo snapshot</span></div>
<section class="cards" aria-label="Saúde dos agents">
{% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("unknown","Unknown")] %}<article class="metric"><span>{{ label }}</span><strong>{{ summary[key] }}</strong></article>{% endfor %}
<div class="page-heading">
<div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1><p class="muted">Somente instalações ativas. Histórico permanece disponível nas listagens.</p></div>
<span class="live-dot">Atualizado pelo snapshot</span>
</div>
<section class="cards" aria-label="Saúde dos servidores">
{% for key,label,health in [("agents_total","Servidores",none),("online","Online","ONLINE"),("degraded","Atenção","DEGRADED"),("offline","Offline","OFFLINE"),("failed","Falhou","FAILED"),("unknown","Não informado","UNKNOWN")] %}
<a class="metric metric-link" href="/agents?scope=active{% if health %}&amp;health={{ health }}{% endif %}" aria-label="{{ label }}: {{ summary[key] }}. Ver servidores{% if health %} filtrados{% endif %}">
<span>{{ label }}</span><strong>{{ summary[key] }}</strong><small>Ver servidores <span aria-hidden="true">→</span></small>
</a>
{% endfor %}
</section>
<section class="split">
<article class="panel"><div class="panel-title"><h2>Cobertura dos produtos</h2><a href="/agents">Ver instalações</a></div><div class="coverage">{% for key,label in [("total_products","Produtos avaliados"),("fiscally_complete","Completos"),("safe_blocked","Bloqueados com segurança"),("failed_products","Falhas técnicas"),("unclassified_products","Não classificados") ] %}<div><strong>{{ summary[key] }}</strong><span>{{ label }}</span></div>{% endfor %}</div>{% if summary.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada: total = completos + bloqueados com segurança + falhas técnicas + não classificados.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.</p>{% endif %}<div class="registry-summary"><a href="/blockers">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">Abrir fila</a></div>{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b></div>{% endfor %}</article>
<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="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>
{% endfor %}
</div>
{% if summary.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada: total = completos + bloqueados + falhas + não classificados.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.</p>{% endif %}
<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 class="panel">
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&amp;status=OPEN">Abrir fila</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&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 %}
{% endfor %}
</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">Ver todos</a></div>{% for item in summary.attention_items[:10] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.client or "Cliente não identificado" }}</b> · {{ item.display_name or "Instalação" }}<small>{{ item.hostname or "hostname indisponível" }} · {{ item.problem }}</small></span><em class="badge {{ item.technical_health|lower }}">{{ item.age }}</em></a>{% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}</section>
<section class="split">
<article class="panel"><div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?status=FAILED">Ver jobs</a></div>{% if summary.failed_jobs %}<p class="metric-inline"><strong>{{ summary.failed_jobs }}</strong> jobs aguardam investigação.</p><div class="table-wrap"><table><caption>Falhas recentes (máximo 10)</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Job ID / Tipo</th><th>Erro</th><th>Criado</th><th>Finalizado</th></tr></thead><tbody>{% for job in summary.recent_failed_jobs %}<tr><td>{{ job.client or "-" }}</td><td>{{ job.display_name or "-" }}<small>{{ job.hostname or "hostname indisponível" }}</small></td><td><a href="{{ job.link }}">{{ job.job_id }}</a><small>{{ job.kind }}</small></td><td>{{ job.error or "Sem detalhe de erro" }}</td><td title="{{ job.created_at }}">{{ job.created_display }}<small>{{ job.created_age }}</small></td><td title="{{ job.finished_at }}">{{ job.finished_display }}<small>{{ job.finished_age }}</small></td></tr>{% endfor %}</tbody></table></div>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}</article>
<article class="panel"><div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?sort=worst_coverage">Ver agents</a></div>{% for item in summary.outdated_agents[:10] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client }}</b> · {{ item.display_name }}<small>{{ item.hostname or "hostname indisponível" }} · {{ item.agent_version or "versão desconhecida" }}</small></span><em class="badge warning">Desatualizado</em></a>{% else %}<p class="empty-state">Nenhum agent desatualizado.</p>{% endfor %}</article>
<section 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>
{% 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 %}
</section>
<section class="split jobs-overview">
<article class="panel">
<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>
<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&amp;status=FAILED&amp;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 %}
</article>
<article class="panel">
<div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?scope=active&amp;version=outdated">Ver todos</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>
{% endblock %}
+18 -4
View File
@@ -1,8 +1,22 @@
{% extends "base.html" %}
{% block title %}Jobs | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs globais</h1><p class="muted">Acompanhe o que está em andamento e onde falhou.</p></div></div>
<section class="panel"><form class="filters" method="get"><label>Status<select name="status"><option value="">Todos</option>{% for value in ["PENDING","RUNNING","COMPLETED","FAILED"] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Cliente, instalação ou busca<input name="search" value="{{ search }}"></label><label>Tipo<input name="kind" value="{{ kind_filter }}"></label><button class="secondary" type="submit">Filtrar jobs</button></form></section>
<div class="panel table-wrap"><table><caption>Execuções recentes</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Job ID</th><th>Tipo</th><th>Status</th><th>Criado</th><th>Finalizado</th><th>Duração</th><th>Erro</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or "Cliente não identificado" }}</td><td>{{ item.display_name or "-" }}</td><td>{{ item.hostname or "-" }}</td><td>#{{ item.job_id }}<small>Agent técnico {{ item.agent_id }}</small></td><td>{{ item.kind }}</td><td><span class="badge status-{{ item.status|lower }}">Status: {{ item.status }}</span></td><td title="{{ item.created_at }}">{{ item.created_display }}</td><td title="{{ item.finished_at }}">{{ item.finished_display }}</td><td>{{ item.duration }}</td><td class="error-text">{{ item.error or "-" }}</td></tr>{% else %}<tr><td colspan="10" class="empty-state">Nenhum job encontrado para os filtros atuais.</td></tr>{% endfor %}</tbody></table></div>
<nav class="pagination" aria-label="Paginação de jobs">{% if result.page > 1 %}<a href="/jobs?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} jobs</span>{% if result.page * result.page_size < result.total %}<a href="/jobs?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs</h1><p class="muted">Acompanhe execuções ativas e investigue falhas sem perder o histórico.</p></div></div>
<section class="cards compact jobs-kpis" aria-label="Jobs por status">
{% for value,label in [("PENDING","Pendentes"),("RUNNING","Executando"),("COMPLETED","Concluídos"),("FAILED","Falhos")] %}<a class="metric metric-link{% if status_filter==value %} metric-selected{% endif %}" href="/jobs?scope={{ scope_filter }}&amp;status={{ value }}"><span>{{ label }}</span><strong>{{ status_counts[value] }}</strong><small>Ver jobs →</small></a>{% endfor %}
</section>
<section class="panel filter-panel"><form class="filters" method="get">
<label>Status<select name="status"><option value="">Todos</option>{% for value,label in [("PENDING","Pendente"),("RUNNING","Executando"),("COMPLETED","Concluído"),("FAILED","Falhou")] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label class="filter-search">Cliente, instalação ou busca<input name="search" value="{{ search }}" type="search"></label>
<label>Tipo<input name="kind" value="{{ kind_filter }}" placeholder="Ex.: SYNC_INCREMENTAL"></label>
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
{% if job_id_filter %}<input type="hidden" name="job_id" value="{{ job_id_filter }}">{% endif %}
<button class="secondary" type="submit">Filtrar jobs</button>
{% if status_filter or search or kind_filter or job_id_filter or scope_filter=="all" %}<a class="secondary filter-clear" href="/jobs?scope=active">Limpar filtros</a>{% endif %}
</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 %}
<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 %}
</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>
{% endblock %}
+72 -13
View File
@@ -80,11 +80,11 @@ def test_dashboard_renders_operational_queue_labels_and_human_dates():
response = client.get("/")
assert response.status_code == 200
for label in ("Agents total", "Online", "Degraded", "Offline", "Failed", "Unknown"):
for label in ("Servidores", "Online", "Atenção", "Offline", "Falhou", "Não informado"):
assert label in response.text
assert "Produtos com 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 "Jobs com falha" in response.text
assert "Agents desatualizados" in response.text
@@ -103,6 +103,68 @@ def test_dashboard_coverage_displays_reconciled_product_buckets():
assert "Cobertura reconciliada" in response.text
def test_agents_scope_querystring_distinguishes_active_inventory_from_history():
client, _, _ = _panel_client()
with client._session_factory() as session:
tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme"))
tenant.active = False
session.commit()
current = client.get("/agents?scope=active")
history = client.get("/agents?scope=all")
assert current.status_code == history.status_code == 200
assert "Acme ERP" not in current.text
assert "Acme ERP" in history.text
def test_blockers_and_jobs_scope_routes_keep_history_and_server_filters():
client, agent_id, _ = _panel_client()
with client._session_factory() as session:
agent = session.get(Agent, agent_id)
agent.enabled = False
session.commit()
assert "P-001" not in client.get("/blockers?scope=active&field=CFOP&reason_code=MISSING_CONTEXT").text
assert "P-001" in client.get("/blockers?scope=all&field=CFOP&reason_code=MISSING_CONTEXT").text
assert "Acme ERP" not in client.get("/jobs?scope=active&status=RUNNING").text
assert "Acme ERP" in client.get("/jobs?scope=all&status=RUNNING").text
def test_dashboard_links_reach_scoped_health_blocker_failed_job_and_outdated_filters():
client, agent_id, _ = _panel_client()
with client._session_factory() as session:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
upsert_blocker(session, installation.tenant_id, {"company": "Acme", "product_code": "P-NCM", "description": "Mouse GTI", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "NCM", "reason_code": "INVALID_NCM", "input_fingerprint": "ncm-link"}, installation_id=installation.id)
session.add(Job(agent_id=agent_id, kind="SYNC_INCREMENTAL", status="FAILED", created_at=datetime.now(timezone.utc), error="falha longa"))
session.commit()
home = client.get("/").text
assert 'href="/agents?scope=active"' in home
assert 'href="/agents?scope=active&amp;health=ONLINE"' in home
assert 'href="/blockers?scope=active&amp;status=OPEN&amp;field=NCM"' in home
assert 'href="/jobs?scope=active&amp;status=FAILED"' in home
assert 'href="/agents?scope=active&amp;version=outdated"' in home
def test_list_filters_are_visible_and_clearable_with_ptbr_statuses():
client, _, _ = _panel_client()
agents = client.get("/agents?scope=active&health=ONLINE").text
blockers = client.get("/blockers?scope=active&field=CFOP").text
jobs = client.get("/jobs?scope=active&status=RUNNING").text
assert 'name="health"' in agents and 'value="ONLINE" selected' in agents
assert 'href="/agents?scope=active"' in agents and "Limpar filtros" in agents
assert "Saúde técnica: Online" not in agents
assert 'name="field"' in blockers and 'value="CFOP" selected' in blockers
assert "Evidência fiscal ausente" in blockers and "Prioridade: HIGH" not in blockers
assert 'href="/blockers?scope=active"' in blockers and "Limpar filtros" in blockers
assert 'value="RUNNING" selected' in jobs and "Executando" in jobs
assert 'href="/jobs?scope=active"' in jobs and "Limpar filtros" in jobs
def test_login_has_centered_card_and_explicit_field_labels():
client, _, _ = _panel_client()
@@ -126,12 +188,10 @@ 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 "Job ID" in response.text and "Criado" in response.text
assert 'href="/jobs?job_id=' in response.text
assert "erro curto" in response.text and "Tipo / erro" in response.text and "Falhou" 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 'title="2026-09-22T18:00:11+00:00"' in response.text
assert 'title="2026-09-22T18:05:11+00:00"' in response.text
assert ">22/09/2026 15:00<" in response.text
assert ">22/09/2026 15:05<" in response.text
with factory() as session:
session.query(Job).filter(Job.status == "FAILED").delete()
@@ -176,7 +236,7 @@ def test_agents_render_separate_health_and_fiscal_status_with_server_sort():
response = client.get("/agents?sort=worst_coverage&health=ONLINE&search=Acme")
assert response.status_code == 200
for label in ("Cliente", "Nome de exibição", "Hostname", "Saúde técnica", "Status fiscal", "Cobertura fiscal", "Versão"):
for label in ("Cliente / servidor", "Saúde", "Status fiscal", "Cobertura", "Produtos", "Versão", "Último contato"):
assert label in response.text
assert "Acme ERP" in response.text
assert "srv-01" in response.text
@@ -222,9 +282,8 @@ def test_agent_list_hides_technical_uuid_and_explains_remainder():
assert response.status_code == 200
assert technical_uuid not in response.text
assert "classificados" in response.text
assert "bloqueados com segurança" in response.text
assert "falhas técnicas" in response.text
assert "bloqueados" in response.text
assert "Não informada" in response.text
assert "<td>Desconhecida<small>Desconhecida</small></td>" not in response.text
@@ -254,7 +313,7 @@ def test_jobs_render_client_identity_duration_and_preserve_filters():
response = client.get("/jobs?status=RUNNING&search=Acme")
assert response.status_code == 200
for label in ("Cliente", "Nome de exibição", "Hostname", "Job ID", "Tipo", "Status", "Criado", "Finalizado", "Duração", "Erro"):
for label in ("Cliente / servidor", "Tipo / job", "Status", "Criado", "Finalizado / duração", "Erro / ação"):
assert label in response.text
assert "Acme" in response.text
assert "srv-01" in response.text
@@ -300,10 +359,10 @@ def test_blockers_render_visible_filters_summary_priority_and_detail_link():
response = client.get("/blockers?client=Acme&installation=srv-01&field=CFOP&blocker_type=BLOCKED_MISSING_EVIDENCE&status=OPEN&age=0h&search=P-001")
assert response.status_code == 200
for label in ("Cliente", "Instalação / Agent / Servidor", "Campo", "Tipo de blocker", "Status", "Idade", "Produto ou busca", "Blockers abertos", "Produtos afetados", "Clientes afetados", "CFOP", "ST", "ICMS", "CSOSN", "NCM", "OTHER", "Prioridade"):
for label in ("Cliente", "Instalação / servidor", "Campo", "Tipo de blocker", "Status", "Idade", "Produto ou busca", "Blockers abertos", "Produtos afetados", "Clientes afetados", "CFOP", "ST", "ICMS", "CSOSN", "NCM", "Outros", "Prioridade"):
assert label in response.text
assert f"/blockers/{blocker_id}" in response.text
assert "CRITICAL" in response.text or "HIGH" in response.text
assert "Alta" in response.text and "Evidência fiscal ausente" in response.text
assert 'value="Acme"' in response.text
assert 'value="BLOCKED_MISSING_EVIDENCE"' in response.text
assert "window.confirm" not in response.text
+189 -10
View File
@@ -5,15 +5,27 @@ from sqlalchemy.dialects import postgresql
from central.app.audit import record_audit
from central.app.blocked_products import upsert_blocker
from central.app.control_plane import _blocker_breakdown_query, dashboard_summary, list_blockers, list_installations, list_jobs
from central.app.control_plane import _blocker_breakdown_query, blocker_filter_summary, dashboard_summary, jobs_status_summary, list_blockers, list_installations, list_jobs
from central.app.config import settings
from central.app.events import record_event
from central.app.models import Agent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
from central.app.models import Agent, AgentEnrollment, AgentRelease, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
from central.app.panel_presentation import blocker_reason_label, blocker_type_label, fiscal_status_label, job_kind_label, job_status_label, priority_label
from central.tests.test_control_plane import setup_db
NOW = datetime(2026, 9, 22, 19, 0, tzinfo=timezone.utc)
def test_operator_labels_are_ptbr_without_changing_internal_codes():
assert job_status_label("FAILED") == "Falhou"
assert job_status_label("RUNNING") == "Executando"
assert job_kind_label("SYNC_INCREMENTAL") == "Sincronização incremental"
assert blocker_type_label("BLOCKED_MISSING_EVIDENCE") == "Evidência fiscal ausente"
assert blocker_reason_label("INVALID_NCM") == "NCM inválido"
assert priority_label("HIGH") == "Alta"
assert fiscal_status_label("BLOCKED") == "Bloqueado"
def _installation(session, index, *, health="ONLINE", coverage=80, blocked=0, version="1.0.0rc1"):
tenant = Tenant(name=f"Client {index}")
session.add(tenant)
@@ -80,14 +92,14 @@ def _blocker(session, tenant, *, product, field="CFOP", blocker_type="BLOCKED_MI
def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_products():
session_factory = setup_db()
with session_factory() as session:
first = _installation(session, 1, health="ONLINE", blocked=2)[0]
second = _installation(session, 2, health="DEGRADED")[0]
first, _, first_installation, _ = _installation(session, 1, health="ONLINE", blocked=2)
second, _, second_installation, _ = _installation(session, 2, health="DEGRADED")
_installation(session, 3, health="OFFLINE")
_installation(session, 4, health="FAILED")
_installation(session, 5, health="UNKNOWN")
_blocker(session, first, product="P1", field="CFOP", index=1)
_blocker(session, first, product="P1", field="NCM", index=2)
_blocker(session, second, product="P2", field="ICMS", index=3)
_blocker(session, first, product="P1", field="CFOP", index=1, installation_id=first_installation.id)
_blocker(session, first, product="P1", field="NCM", index=2, installation_id=first_installation.id)
_blocker(session, second, product="P2", field="ICMS", index=3, installation_id=second_installation.id)
session.commit()
summary = dashboard_summary(session, now=NOW)
@@ -100,6 +112,173 @@ def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_produc
assert summary["online"] == 1 and summary["safe_blocked"] == 2
def test_installation_inventory_active_scope_excludes_inactive_and_revoked_but_keeps_history():
factory = setup_db()
with factory() as session:
active = _installation(session, 1)[2]
inactive_tenant, _, _, _ = _installation(session, 2)
inactive_tenant.active = False
_, disabled_agent, _, _ = _installation(session, 3)
disabled_agent.enabled = False
tenant, revoked_agent, revoked, _ = _installation(session, 4, health="UNKNOWN")
_, _, paused, _ = _installation(session, 5)
paused.processing_enabled = False
release = AgentRelease(version="test-inventory", platform="windows", architecture="x64", filename="test.exe", storage_path="test.exe", size_bytes=1, sha256="x" * 64)
session.add(release)
session.flush()
session.add(AgentEnrollment(enrollment_uuid=revoked.installation_id, tenant_id=tenant.id, agent_id=revoked_agent.id, display_name="Histórico", release_id=release.id, status="REVOKED"))
session.commit()
current = list_installations(session, page_size=10, now=NOW)
historical = list_installations(session, page_size=10, scope="all", now=NOW)
unknown = list_installations(session, health="UNKNOWN", now=NOW)
assert {row["installation_id"] for row in current["items"]} == {active.installation_id, paused.installation_id}
assert current["total"] == 2
assert historical["total"] == 5
assert unknown["total"] == 0
assert {row["installation_id"]: row["operational_active"] for row in historical["items"]}[revoked.installation_id] is False
def test_outdated_agent_filter_returns_only_outdated_active_installations():
factory = setup_db()
with factory() as session:
_, _, outdated, _ = _installation(session, 1, version="0.9.0")
_, _, _, current_snapshot = _installation(session, 2, version=settings.agent_server_version)
current_snapshot.version_drift = "CURRENT"
_, disabled, _, _ = _installation(session, 3, version="0.9.0")
disabled.enabled = False
session.commit()
result = list_installations(session, version="outdated", now=NOW)
assert result["total"] == 1
assert result["items"][0]["installation_id"] == outdated.installation_id
def test_dashboard_excludes_inactive_history_and_groups_repeated_blockers():
factory = setup_db()
with factory() as session:
tenant, agent, installation, snapshot = _installation(session, 1, health="ONLINE", coverage=74, blocked=26, version=settings.agent_server_version)
snapshot.version_drift = "CURRENT"
for index in range(26):
_blocker(session, tenant, product=f"P{index:03}", field="NCM", index=index, installation_id=installation.id)
session.add(Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="FAILED", error="erro real", created_at=NOW))
old_tenant, old_agent, old_installation, _ = _installation(session, 2, health="UNKNOWN", version="0.9.0")
_blocker(session, old_tenant, product="HISTORICAL", field="CFOP", index=80, installation_id=old_installation.id)
session.add(Job(agent_id=old_agent.id, kind="REPROCESS_ALL", status="FAILED", error="erro histórico", created_at=NOW))
release = AgentRelease(version="test-dashboard", platform="windows", architecture="x64", filename="test.exe", storage_path="test.exe", size_bytes=1, sha256="y" * 64)
session.add(release)
session.flush()
session.add(AgentEnrollment(enrollment_uuid=old_installation.installation_id, tenant_id=old_tenant.id, agent_id=old_agent.id, display_name="Encerrada", release_id=release.id, status="REVOKED"))
inactive_tenant, _, _, _ = _installation(session, 3, health="OFFLINE")
inactive_tenant.active = False
_, disabled_agent, _, _ = _installation(session, 4, health="FAILED")
disabled_agent.enabled = False
session.commit()
summary = dashboard_summary(session, now=NOW)
assert summary["agents_total"] == 1
assert summary["health_counts"] == {"ONLINE": 1, "DEGRADED": 0, "OFFLINE": 0, "FAILED": 0, "UNKNOWN": 0}
assert summary["total_products"] == 100
assert summary["safe_blocked"] == 26
assert summary["open_blockers"] == 26
assert summary["safe_blocked_products"] == 26
assert summary["open_blocker_breakdown"]["NCM"] == 26
assert summary["open_blocker_breakdown"]["CFOP"] == 0
assert summary["failed_jobs"] == 1
fiscal_groups = [item for item in summary["attention_items"] if item.get("field") == "NCM"]
assert len(fiscal_groups) == 1
assert fiscal_groups[0]["group_count"] == 26
assert fiscal_groups[0]["products_affected"] == 26
assert fiscal_groups[0]["link"].startswith("/blockers?")
assert len(summary["attention_items"]) <= 10
assert all(item["client"] != "Client 2" for item in summary["attention_items"])
def test_blockers_and_jobs_keep_revoked_history_out_of_active_scope():
factory = setup_db()
with factory() as session:
tenant, agent, installation, _ = _installation(session, 1)
current = _blocker(session, tenant, product="CURRENT", field="NCM", index=1, installation_id=installation.id)
current.reason_code = "INVALID_NCM"
resolved = _blocker(session, tenant, product="RESOLVED", field="NCM", index=2, installation_id=installation.id)
resolved.resolved_at = NOW
session.add(Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="FAILED", created_at=NOW, error="current"))
old_tenant, old_agent, old_installation, _ = _installation(session, 2)
_blocker(session, old_tenant, product="OLD", field="NCM", index=3, installation_id=old_installation.id)
session.add(Job(agent_id=old_agent.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW, error="historical"))
old_agent.enabled = False
session.commit()
assert list_blockers(session, field="NCM")["total"] == 1
assert list_blockers(session, field="NCM", reason_code="INVALID_NCM")["items"][0]["id"] == current.id
assert list_blockers(session, field="NCM", scope="all")["total"] == 2
assert list_blockers(session, status="RESOLVED", scope="all")["items"][0]["id"] == resolved.id
assert blocker_filter_summary(session)["open_blockers"] == 1
assert list_jobs(session, status="FAILED")["total"] == 1
assert list_jobs(session, status="FAILED", scope="all")["total"] == 2
def test_jobs_status_kpis_count_active_jobs_by_status_in_one_group():
factory = setup_db()
with factory() as session:
_, agent, _, _ = _installation(session, 1)
_, disabled, _, _ = _installation(session, 2)
disabled.enabled = False
for status in ("PENDING", "RUNNING", "COMPLETED", "FAILED"):
session.add(Job(agent_id=agent.id, kind="SYNC_INCREMENTAL", status=status, created_at=NOW))
session.add(Job(agent_id=disabled.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW))
session.commit()
assert jobs_status_summary(session) == {"PENDING": 1, "RUNNING": 1, "COMPLETED": 1, "FAILED": 1}
assert jobs_status_summary(session, scope="all")["FAILED"] == 2
def test_attention_groups_rank_high_priority_before_limiting_recent_low_priority():
factory = setup_db()
with factory() as session:
tenant, _, installation, _ = _installation(session, 1, version=settings.agent_server_version)
high = _blocker(session, tenant, product="IMPORTANT", field="NCM", index=90, installation_id=installation.id)
high.last_seen_at = NOW - timedelta(days=3)
for index in range(12):
low = _blocker(session, tenant, product=f"LOW-{index}", field="OTHER", blocker_type="UNMAPPED", index=index, installation_id=installation.id)
low.reason_code = f"OTHER_{index}"
low.last_seen_at = NOW - timedelta(minutes=index)
session.commit()
attention = dashboard_summary(session, now=NOW)["attention_items"]
assert len(attention) <= 10
assert any(item.get("field") == "NCM" for item in attention)
def test_dashboard_query_count_stays_bounded_with_many_blockers():
factory = setup_db()
with factory() as session:
tenant, _, installation, _ = _installation(session, 1, version=settings.agent_server_version)
for index in range(130):
_blocker(session, tenant, product=f"P-{index:03}", field="NCM", index=index, installation_id=installation.id)
session.commit()
statements = []
def count_statement(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
event.listen(session.bind, "before_cursor_execute", count_statement)
try:
summary = dashboard_summary(session, now=NOW)
finally:
event.remove(session.bind, "before_cursor_execute", count_statement)
assert summary["open_blockers"] == 130
assert len(statements) <= 18
assert not any("SELECT" in sql.upper() and "LIMIT 130" in sql.upper() for sql in statements)
def test_snapshot_coverage_exposes_unclassified_residual_and_overcount():
session_factory = setup_db()
with session_factory() as session:
@@ -290,7 +469,7 @@ def test_legacy_tenant_blocker_has_no_fabricated_installation_context():
with session_factory() as session:
tenant, _, installation, _ = _installation(session, 1)
row = _blocker(session, tenant, product="LEGACY", index=99)
result = list_blockers(session, tenant_id=tenant.id)
result = list_blockers(session, tenant_id=tenant.id, scope="all")
assert result["items"][0]["id"] == row.id
assert result["items"][0]["installation_id"] is None
@@ -370,11 +549,11 @@ def test_lowercase_required_blocker_field_reaches_attention_candidates():
session_factory = setup_db()
with session_factory() as session:
tenant, _, installation, _ = _installation(session, 1)
blocker = _blocker(session, tenant, product="LOWER", field="cfop", index=120, installation_id=installation.id)
_blocker(session, tenant, product="LOWER", field="cfop", index=120, installation_id=installation.id)
attention = dashboard_summary(session, now=NOW)["attention_items"]
assert any(item["link"] == f"/blockers/{blocker.id}" for item in attention)
assert any(item.get("field") == "CFOP" and item["link"].startswith("/blockers?scope=active") for item in attention)
def test_blocker_breakdown_query_groups_on_same_postgres_expression():