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():
@@ -0,0 +1,77 @@
# GTI Fiscal Operational UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Work inline; the operator authorized proceeding without additional design confirmation.
**Goal:** Make Resumo, Agents, Blockers and Jobs a fast, accessible, PT-BR operational workflow where each important aggregate reaches a filtered detail in one click, with revoked/inactive history excluded from live indicators.
**Architecture:** A single SQLAlchemy active-installation predicate is shared by dashboard aggregates and paged list queries. Small presenter helpers translate labels; Jinja templates render native links and bounded summaries using Gerenciador tokens. Routes preserve GET querystrings and offer explicit active/all history scopes.
**Tech Stack:** Existing FastAPI, SQLAlchemy, Jinja, CSS tokens, minimal vanilla JS, pytest, Playwright, `scripts/deploy_hub.ps1`.
## Global Constraints
- `OPERATIONALLY_ACTIVE`: `Tenant.active IS TRUE AND Agent.enabled IS TRUE`, excluding linked `AgentEnrollment` status `REVOKED`, `FAILED`, `EXPIRED`; no inference from client/installation names.
- `Installation.processing_enabled=false` means paused jobs, not inactive server; keep it in health counts. Agents without linked enrollment remain active if Tenant and Agent are active.
- Preserve history via `scope=all`; default `scope=active` across operational lists. Existing resolved blocker/history URLs remain reachable.
- Preserve GTI Gerenciador client combobox, SSO, themes, HTTPS, Agent/runtime, fiscal engine and `FIREBIRD_WRITES=0`; no deletion of historical data.
- All filtering and pagination server-side (50/page); bounded SQL aggregates before LIMIT 10; no N+1 or client-side loading of products.
- Do not commit unless explicitly asked. Deploy exclusively through `scripts/deploy_hub.ps1`; no ad-hoc SSH/deploy script.
---
### Task 1: Shared active scope and paginated inventory
**Files:** `central/app/control_plane.py`, `central/tests/test_operational_view_models.py`, `central/app/web.py`.
**Interfaces:** `operational_installation_predicate()` yields a SQLAlchemy condition on Tenant/Agent/Installation, using `~exists(select(AgentEnrollment.id).where(AgentEnrollment.agent_id == Agent.id, AgentEnrollment.status.in_(["REVOKED", "FAILED", "EXPIRED"])))`. `list_installations(..., scope="active", version=None)` accepts `scope=active|all`; `version="outdated"` restricts to outdated versions. Web route validates scope/health/sort/version before querying and passes `query_base` with these filters.
- [ ] RED: seed active, Tenant.active=False, Agent.enabled=False, ENROLLED and REVOKED linked cases and assert default `list_installations` contains only active ids; `scope="all"` contains every id and preserves `health` filtering. Assert a `processing_enabled=False` active installation remains visible. Run `python -m pytest central/tests/test_operational_view_models.py -q` and confirm missing scope behavior fails.
- [ ] GREEN: add `AgentEnrollment` import/predicate, apply same predicate before both paged list and count; support `version="outdated"` via existing drift expression. Preserve SQL `LIMIT/OFFSET` and existing joins. Re-run focused test.
- [ ] RED/GREEN: web TestClient `/agents?scope=active&health=UNKNOWN` and `/agents?scope=all` reflect selected filter, pagination links preserve scope; implement route validation and query_base, rerun `python -m pytest central/tests/test_control_panel_pages.py -q`.
### Task 2: Operational dashboard aggregates and grouped attention
**Files:** `central/app/control_plane.py`, `central/tests/test_operational_view_models.py`.
**Interfaces:** `dashboard_summary` returns same keys as today plus grouped `attention_items` (<=10) with `group_count`, `products_affected`, `link`, `priority` and client/server identity. Blocker totals and failed-job counts only include open/current records attributed to active installations. Limit candidate groups after SQL `GROUP BY (installation_id, field, blocker_type, reason_code)`; lower-case fields normalized to upper-case for breakdown. Dashboard does not load all blockers/products.
- [ ] RED: seed revoked Agent enrollment with UNKNOWN snapshot, inactive tenant, disabled Agent, 26 NCM blockers on one active installation plus historical resolved blockers; assert active health/coverage/blocker/job aggregates exclude inactive rows, NCM becomes one attention group with 26 products, and `len(attention_items) <= 10`. Run focused operational test and observe incorrect counts/duplicate rows.
- [ ] GREEN: reuse Task 1 predicate for health/snapshots/outdated/failed jobs. For blocker aggregation join Tenant→Installation→Agent, filter active/open; count distinct `(tenant_id, product_code)` and compute grouped fields before `.limit(10)`, then order technical alerts before fiscal groups and outdated. Keep a bounded fixed number of SQL queries independent of product volume. Re-run focused test.
- [ ] Measure number of executed SQL statements with SQLAlchemy `before_cursor_execute` for 1 vs 26 blockers; assert bounded query count and no statement per blocker. Compare SQL GROUP BY query on SQLite and PostgreSQL dialect compilation; inspect indexes from `blocked_products(installation_id,resolved_at,field)` and `jobs(agent_id,status)` before adding migration.
### Task 3: Scoped Blockers and Jobs data models
**Files:** `central/app/control_plane.py`, `central/app/web.py`, `central/tests/test_operational_view_models.py`, `central/tests/test_control_panel_pages.py`.
**Interfaces:** `list_blockers`/`blocker_filter_summary(..., scope="active")` and `list_jobs(..., scope="active")` reuse Task 1 active condition; `jobs_status_summary(..., scope="active")` returns counts PENDING/RUNNING/COMPLETED/FAILED using one GROUP BY. Web GET routes accept scope, keep valid filters/query_base on pagination, and expose a selected `job_id` result with untruncated error. `status=RESOLVED&scope=all` retains historic blocker access.
- [ ] RED: tests for active defaults excluding revoked/inactive records, `scope="all"` preserving history, `status="RESOLVED"`, `/blockers?field=NCM&scope=active`, `/jobs?status=FAILED&scope=active&job_id=123`, and no product dropdown; run focused tests and see active-scope failures.
- [ ] GREEN: apply active predicate after existing joins (for blocker query, require linked active installation only when `scope=active`); keep `page_size=50`, filters and count separate. Add small status GROUP BY and route params. Render one selected job detail from the returned page data or a single scoped `job_id` query; full error only there. Re-run focused tests.
### Task 4: Human-readable presentation and dashboard/filters markup
**Files:** `central/app/panel_presentation.py`, `central/templates/dashboard.html`, `central/templates/agents.html`, `central/templates/blockers.html`, `central/templates/jobs.html`, `central/tests/test_control_panel_pages.py`, `central/tests/test_operational_view_models.py`.
**Interfaces:** `job_status_label`, `job_kind_label`, `blocker_type_label`, `blocker_reason_label`, `priority_label`, `fiscal_status_label` map known enums, falling back to readable text; retain raw value on details. All dashboard health cards are `<a class="metric metric-link" href="/agents?scope=active&health=...">`; blocker field positive links preserve scope/status; zero stays text. Filter chips and clear links display active selections.
- [ ] RED: TestClient HTML assertions for card→health filter, breakdown NCM→field, failed jobs→FAILED, outdated→version, selected filters/clear actions, human PT-BR labels; confirm old HTML fails.
- [ ] GREEN: add presenter mappings to `panel_presentation.py` and include presentation fields in view-model dictionaries; render cards as links, attention groups with count/CTA, compact failed jobs/outdated; edit Agents table to combine identity columns, Blockers table product link + human labels, Jobs compact columns + detail/error. Keep historical routes intact, use `query_base`/`urlencode` to preserve filters and avoid XSS/unescaped params. Re-run HTML assertions.
- [ ] Retain the Nova instalação combobox unchanged: TestClient asserts `client_id` hidden, searchable active Gerenciador-only clients, keyboard/mouse JS and server-side validation still present.
### Task 5: Interaction/CSS and responsive presentation
**Files:** `central/static/control-panel.css`, `central/static/control-panel.js`, templates from Task 4.
- [ ] RED: browser preview at 1366/1920/375 exposes horizontal scroll on Agents today and non-clickable cards; capture snapshot/measure `scrollWidth`, focus order and toolbar visibility.
- [ ] GREEN: use only `var(--cor-*)` tokens for `.metric-link:hover/:focus-visible`, active-filter chips, grouped attention, semantic badges, compact tables and responsive 375px row layout. Set Agents table sizing to available content area and hide secondary fields below tablet; no 960px minimum for Agents on desktop. Keep Blockers/Jobs list bounded and filters stacked on mobile. Vanilla JS event delegation on `tr[data-href]` for mouse clicks outside existing links; keep primary `<a>` for Tab/Enter and avoid nested controls/intercepting modifier clicks.
- [ ] Browser check at 1366x768, 1920x1080, 375px for focus-visible, Tab/Enter (and Space on buttons), no desktop horizontal scroll in Agents, readable names, light/dark styles. Adjust CSS and repeat visual checks until criteria pass.
### Task 6: Full verification and official deploy
**Files:** `scripts/deploy_hub.ps1` (execute only; do not edit), test suites.
- [ ] Run `python -m pytest central/tests -q`, `python -m pytest tests -q`, and `python -m pytest agent/tests -q`; capture totals and failures. Verify no runtime/engine/SSO/auth files changed; `git diff --check` and inspect status/diff.
- [ ] Verify dashboard query count and, if SQL plan shows a missing index, first add a small Alembic migration plus migration test, re-run affected and full tests, and deploy with the same official script. No migrations solely for aesthetics.
- [ ] Use authenticated browser smoke on Resumo, Agents, Blockers, Jobs and Nova instalação; click Resumo→Online/Total/NCM/failed job/outdated Agent and navigate back. Recheck server-side selected filters and disabled/revoked exclusion at 1920x1080, 1366x768, 375px in light and dark. If production SSO login is unavailable, do equivalent smoke with TestClient/local preview and report production visual check as blocked.
- [ ] Run only `powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1` (or `pwsh -File .\scripts\deploy_hub.ps1` if script requires pwsh) as documented in `docs/DEPLOY_HUB.md`. Check `logs/last_deploy_result.json`, release health and public `https://gtifiscal.gtigrupo.com.br/health`. No ad-hoc SSH, no write to Firebird, no client action.
- [ ] Report the user-requested field list with observed evidence; `COMMITS=nenhum` if no explicit request to commit, and truthful blockers where browser authentication/production smoke cannot be completed.
@@ -0,0 +1,41 @@
# GTI Fiscal — painel operacional com drill-down
**Objetivo.** Refinar as telas atuais Resumo, Agents, Blockers, Jobs e Nova instalação para operação de ~70 clientes, inclusive clientes com 20.000+ produtos. Preservar visual do Gerenciador, stack Jinja/FastAPI/SQLAlchemy e funcionalidades fiscais, Agent, SSO e sincronização existentes. Sem gráficos decorativos, framework frontend novo nem carga de produtos no browser.
## Semântica operacional e histórico
Uma expressão SQL compartilhada define `OPERATIONALLY_ACTIVE` para instalação vinculada a Agent: `Tenant.active IS TRUE`, `Agent.enabled IS TRUE` e ausência de enrollment vinculado em estado `REVOKED`, `EXPIRED` ou `FAILED` (estados finais/inativos). Agent legado sem enrollment não é revogado por inferência; clientes `LOCAL` não são classificados por nome. `Installation.processing_enabled=false` pausa processamento, **não** equivale a instalação revogada: ainda pode comunicar heartbeat e indicar indisponibilidade. Nunca excluir registros por nome `E2E` ou por versão antiga. O registro histórico permanece intacto.
Dashboard usa exclusivamente esta expressão para contagem de saúde, soma de snapshots/cobertura, blockers operacionais, jobs falhos operacionais, versão desatualizada e atenção. Blockers ativos requerem instalação vinculada operacionalmente ativa e `resolved_at IS NULL`; blockers legados sem instalação e blockers resolvidos permanecem nas listagens históricas. A página Agents usa `scope=active` por padrão e `scope=all` para inventário/histórico (com indicação de histórico e badges); Blockers/Jobs usam o mesmo `scope` para a lista e seus KPIs, preservando filtros adicionais. Um card do Resumo sempre leva à visão `scope=active` e ao filtro correspondente. O filtro `scope=all` inclui registros inativos/revogados, e `status=RESOLVED` em Blockers permite acessar problemas resolvidos. Totais do dashboard e contagens da listagem ativa são reconciliados pela mesma expressão SQL.
## Resumo: decisão em um clique
Seis cards `<a>` completos para Total, Online, Degraded, Offline, Failed, Unknown; destino `/agents?scope=active` com `health=<ENUM>` quando aplicável. Labels de apresentação PT-BR: Servidores, Online, Atenção, Offline, Falhou, Não informado. A identidade técnica do filtro permanece na URL e na query SQL. Cobertura: total/completos levam a Agents com ordenação fiscal (visão disponível), bloqueados levam a `/blockers?scope=active&status=OPEN`, falhas técnicas a `/agents?scope=active&health=FAILED`, e não classificados a Agents ordenados por pior cobertura. Sem prometer filtros de produto que o sistema não possui. O breakdown CFOP/ST/ICMS/CSOSN/NCM/OTHER liga valores positivos a `/blockers?scope=active&status=OPEN&field=<ENUM>`; zeros ficam visualmente neutros.
“Atenção necessária” usa até 10 **grupos**, não 10 linhas cruas: primeiro instalações com falha técnica, depois saúde Failed/Degraded/Offline, grupos de blockers fiscais por instalação/campo/tipo/motivo, versões desatualizadas e outros estados realmente acionáveis. Dentro do grupo fiscal: número de blockers abertos e produtos distintos; agrupar **em SQL antes do LIMIT**, com ordenação por gravidade e recência. Um grupo oferece link de lista filtrada de blockers ou detalhe de Agent/job. Instalações revogadas, agentes desabilitados, clientes inativos, itens resolvidos ou sem destino operacional não aparecem. Link “Ver todos” abre lista operacional correspondente. Evitar duplicar a mesma instalação como alerta de versão quando um alerta técnico de prioridade maior já a representa; histórico continua nas listas.
Jobs falhos: contagem e lista curta da operação ativa com Cliente, Agent/hostname, tipo, erro truncado, momento e link `Ver job` para `/jobs?scope=active&status=FAILED&job_id=<id>`. A página Jobs exibe erro integral quando `job_id` é informado. Link “Ver jobs” abre `status=FAILED`. Agents desatualizados: lista compacta em largura suficiente (sem sidebar estreita), cliente/hostname, versão atual/atualização e detalhe do Agent; poucos itens mais “Ver todos” com `version=outdated` aplicado.
## Agents / Servidores
Uma tabela densa, legível em 1366×768 sem scroll horizontal: cliente com instalação/hostname na mesma célula, saúde, fiscal (quantidades resumidas), cobertura, produtos, versão e último contato. Texto secundário só quando existir; badges mostram apenas o valor humano, sem repetir “Saúde técnica:” ou “Status fiscal:”. Busca, saúde (todos os cinco estados), ordenação e scope via GET; chip de filtro ativo e “Limpar filtros”; paginação mantém todos os parâmetros. Link primário visível no cliente/hostname é nativamente acessível por Tab/Enter; clique com mouse na restante da linha abre o mesmo detalhe via delegação pequena em JS, sem eliminar a semântica da tabela. Em telas pequenas, linhas podem assumir apresentação compacta com rótulos por célula, mantendo filtros e ações expostos.
## Blockers
KPIs coerentes com `scope`, `status` e demais filtros (abertos, produtos/clientes afetados, resultado filtrado), com links apenas quando a métrica tem destino fiel. Breakdown filtrado liga campo positivo ao mesmo conjunto de filtros, trocando só `field`; filtro selecionado fica visível. Filtros existentes permanecem server-side (cliente, instalação, campo, tipo, status, idade, busca e identificadores existentes), dispostos em uma ou duas linhas no desktop e empilhados no mobile, com “Limpar filtros” retornando à visão ativa padrão.
Tabela destaca código e descrição do produto como link para `/blockers/<id>`; exibe cliente/servidor, campo, tipo humano, prioridade humana, problema humano, último registro e CTA. Tradutor somente de apresentação mapeia enums conhecidos (`BLOCKED_MISSING_EVIDENCE` → `Evidência fiscal ausente`, `INVALID_NCM` → `NCM inválido`, prioridades `CRITICAL/HIGH/MEDIUM/LOW` → `Crítica/Alta/Média/Baixa`) e cai em rótulo legível para desconhecidos; o detalhe mantém o código técnico original. Paginação limitada (50), consulta com COUNT separado, sem dropdown de produtos nem N+1.
## Jobs
Atalhos compactos para `PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, todos filtrando `scope=active` e mostrando a seleção. Status PT-BR: Pendente, Executando, Concluído, Falhou; tipos conhecidos (`SYNC_INCREMENTAL`, `INITIAL_SYNC`, `REPROCESS_ALL`, `REPROCESS_PRODUCTS`) ganham labels humanos. A lista agrupa cliente e servidor, depois tipo, status, criado/finalizado, duração e erro curto. Erro integral e enum técnico aparecem na vista filtrada `job_id` da própria página, sem criar uma tela artificial. Seleção de scope histórico e “Limpar filtros” mantêm os demais parâmetros úteis. Não acionar jobs, somente leitura do banco.
## Estilo, acessibilidade e escala
Usar somente `central/static/control-panel.css` sobre os tokens imutáveis `gti-theme.css`. Estados semânticos: sucesso verde, atenção amarelo, falha vermelha, histórico/desconhecido cinza, bloqueio fiscal laranja. Foco visível, navegação Tab/Enter e Space nos controles apropriados, links `<a>` em cards; nenhum `div` simulando botão. Mobile 375 px preserva busca e ações, desktop 1366×768 e 1920×1080 aproveitam largura sem blocos de vazio. Testar light/dark por screenshot, não só inspecionando tokens. Combobox de clientes ativos da Nova instalação permanece com busca nome/CNPJ, mouse/teclado, `client_id` hidden e validação server-side; só retoque estético se necessário.
Queries devem usar agregados e grupos SQL, bounded LIMIT, COUNT e paginação server-side; não carregar listas de produtos/snapshots inteiros nem emitir uma consulta por item. Medir número de queries do dashboard e inspecionar planos quando filtros/campos precisarem de índice. Se necessário, migration de índices pequena e reversível; nunca mudar engine fiscal ou dados históricos. Regressões confirmam exclusão de inativos/revogados, coerência card→querystring→resultado, agrupamento, labels, paginação e query count/ausência de N+1.
## Validação e deploy
Rodar suites existentes e regressões focadas. Browser smoke em Resumo, Agents, Blockers, Jobs, Nova instalação; navegar Resumo→Online/Total/NCM/Jobs falhos/Agent desatualizado e voltar; verificar light/dark nos viewports 1920×1080, 1366×768, 375 px e Tab/Enter/Space. Nova instalação não deve criar registros no smoke. Usar **exclusivamente** `scripts/deploy_hub.ps1` para deploy/backup/migration/rollback/health, sem SSH improvisado nem scripts paralelos. Verificar health público e métricas operacionais pós-deploy. Se autenticação visual do Gerenciador não estiver disponível, reportar explicitamente o bloqueio em vez de alegar screenshot de produção.