diff --git a/central/app/control_plane.py b/central/app/control_plane.py index ad2116d..5acebc7 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -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 diff --git a/central/app/panel_presentation.py b/central/app/panel_presentation.py index 67cc5d4..ef77de0 100644 --- a/central/app/panel_presentation.py +++ b/central/app/panel_presentation.py @@ -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"}) diff --git a/central/app/web.py b/central/app/web.py index 207f0a3..653596d 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -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 diff --git a/central/static/control-panel.css b/central/static/control-panel.css index acd53ac..754feb1 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -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; } } diff --git a/central/static/control-panel.js b/central/static/control-panel.js index d157cb7..2711710 100644 --- a/central/static/control-panel.js +++ b/central/static/control-panel.js @@ -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; + }); + }); }); diff --git a/central/templates/agents.html b/central/templates/agents.html index 0d89968..3ef3c99 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -1,8 +1,31 @@ {% extends "base.html" %} {% block title %}Agents | GTI Fiscal{% endblock %} {% block content %} -

INVENTÁRIO OPERACIONAL

Agents / Servidores

Identidade do cliente primeiro; IDs técnicos ficam no detalhe.

+ Nova instalação Releases
-
-
{% for item in result["items"] %}{% else %}{% endfor %}
Instalações monitoradas
ClienteNome de exibiçãoHostnameSaúde técnicaStatus fiscalCobertura fiscalProdutosVersãoÚltimo heartbeat
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Não informado" }}{{ item.hostname or "Não informado" }}Saúde técnica: {{ item.technical_health_label }}Status fiscal: {{ item.fiscal_status_label }}{% if item.total_products is none %}Cobertura ainda não informada (nenhum snapshot recebido){% else %}{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados{% if not item.coverage_invariant_pass %}Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}{% endif %}{% endif %}{% if item.fiscal_completion_percent is none %}–Não informado{% else %}{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%{{ item.fiscally_complete }} / {{ item.total_products }} completos{% endif %}{% if item.total_products is none %}Não informado{% else %}{{ item.total_products }}{{ item.failed_products }} falhas técnicas{% endif %}{{ item.agent_version_display }}{{ item.last_seen_display }}{{ item.last_seen_age }}
Nenhuma instalação encontrada.
- +

INVENTÁRIO OPERACIONAL

Agents / Servidores

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

+ Nova instalação Releases
+
+
+ {% if version_filter %}{% endif %} + + + + + + {% if search or health_filter or version_filter or scope_filter=="all" or sort!="display_name" %}Limpar filtros{% endif %} +
+ {% if health_filter or version_filter or scope_filter=="all" %}

Filtros ativos: {% if scope_filter=="all" %}Histórico e inativos{% endif %}{% if health_filter %}Saúde: {{ {"ONLINE":"Online","DEGRADED":"Atenção","OFFLINE":"Offline","FAILED":"Falhou","UNKNOWN":"Não informado"}[health_filter] }}{% endif %}{% if version_filter %}Agents desatualizados{% endif %}

{% endif %} +
+
+{% for item in result["items"] %} + + + + + + + + + +{% else %}{% endfor %} +
Instalações monitoradas · {{ result.total }} resultados
Cliente / servidorSaúdeStatus fiscalCoberturaProdutosVersãoÚltimo contato
Nenhuma instalação encontrada para os filtros atuais. Consultar histórico
+ {% endblock %} diff --git a/central/templates/blockers.html b/central/templates/blockers.html index 6942ddc..5fd6baf 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -1,10 +1,34 @@ {% extends "base.html" %} {% block title %}Blockers | GTI Fiscal{% endblock %} {% block content %} -

FILA FISCAL

Blockers globais

Problemas fiscais separados de falhas técnicas.

-
Blockers abertos{{ summary.open_blockers }}
Produtos afetados{{ summary.affected_products }}
Clientes afetados{{ summary.affected_clients }}
Resultado filtrado{{ result.total }}
-
{% if request.query_params.get('agent_id') %}{% endif %}{% if request.query_params.get('product_code') %}{% endif %}
-

Distribuição dos blockers filtrados

{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ summary.blocker_breakdown[key] }}
{% endfor %}
-
{% for item in result["items"] %}{% else %}{% endfor %}
Fila de blockers
ClienteInstalaçãoProdutoCampoTipo de blockerPrioridadeProblemaÚltima ocorrênciaAção
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "hostname indisponível" }}{{ item.product_code }}{{ item.description or "" }}{{ item.field or "OTHER" }}Fiscal: {{ item.blocker_type }}Prioridade: {{ item.priority }}{{ item.reason }}{{ item.age }}{{ item.last_seen_display }}{{ item.retry_count }} tentativasVer detalhe
Nenhum blocker corresponde aos filtros.
- +

FILA FISCAL

Blockers

Problemas fiscais da operação ativa; histórico e resolvidos disponíveis no filtro.

+
+ Blockers abertos{{ summary.open_blockers }}Ver abertos → +
Produtos afetados{{ summary.affected_products }}
+
Clientes afetados{{ summary.affected_clients }}
+ +
+
+
+ {% if request.query_params.get('agent_id') %}{% endif %} + {% if request.query_params.get('product_code') %}{% endif %} + {% if reason_code_filter %}{% endif %} + + + + + + + + + + {% 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') %}Limpar filtros{% endif %} +
+ {% if field_filter or reason_code_filter or scope_filter=="all" or status_filter!="OPEN" %}

Filtros ativos: {% if scope_filter=="all" %}Histórico e inativos{% endif %}{% if field_filter %}Campo: {{ field_filter }}{% endif %}{% if reason_code_filter %}Motivo: {{ reason_code_filter }}{% endif %}{% if status_filter!="OPEN" %}Status: {{ "Resolvidos" if status_filter=="RESOLVED" else "Todos" }}{% endif %}

{% endif %} +
+

Distribuição dos blockers filtrados

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

OPERAÇÃO CENTRAL

Qual cliente precisa de atenção?

Fila operacional baseada no último snapshot recebido.

Atualizado pelo snapshot
-
- {% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("unknown","Unknown")] %}
{{ label }}{{ summary[key] }}
{% endfor %} +
+

OPERAÇÃO CENTRAL

Qual cliente precisa de atenção?

Somente instalações ativas. Histórico permanece disponível nas listagens.

+ Atualizado pelo snapshot +
+
+ {% 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")] %} + + {{ label }}{{ summary[key] }}Ver servidores + + {% endfor %}
-
-

Cobertura dos produtos

Ver instalações
{% 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") ] %}
{{ summary[key] }}{{ label }}
{% endfor %}
{% if summary.coverage_invariant_pass %}

Cobertura reconciliada: total = completos + bloqueados com segurança + falhas técnicas + não classificados.

{% else %}

Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.

{% endif %}
Produtos com blockers abertos: {{ summary.safe_blocked_products }}Blockers abertos: {{ summary.open_blockers }}
-

Breakdown dos blockers

Abrir fila
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ summary.open_blocker_breakdown[key] }}
{% endfor %}
+
+
+

Cobertura dos produtos

Ver instalações
+
+ {% for key,label,url in [("total_products","Produtos avaliados","/agents?scope=active"),("fiscally_complete","Completos","/agents?scope=active&sort=worst_coverage"),("safe_blocked","Bloqueados com segurança","/blockers?scope=active&status=OPEN"),("failed_products","Falhas técnicas","/agents?scope=active&health=FAILED"),("unclassified_products","Não classificados","/agents?scope=active&sort=worst_coverage")] %} + {{ summary[key] }}{{ label }} + {% endfor %} +
+ {% if summary.coverage_invariant_pass %}

Cobertura reconciliada: total = completos + bloqueados + falhas + não classificados.

{% else %}

Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.

{% endif %} +
Produtos com blockers abertos: {{ summary.safe_blocked_products }}Blockers abertos: {{ summary.open_blockers }}
+
+
-

FILA DE AÇÃO

ATENÇÃO NECESSÁRIA

Ver todos
{% for item in summary.attention_items[:10] %}{{ item.client or "Cliente não identificado" }} · {{ item.display_name or "Instalação" }}{{ item.hostname or "hostname indisponível" }} · {{ item.problem }}{{ item.age }}{% else %}

Nenhum item requer atenção imediata.

{% endfor %}
-
-

Jobs com falha

Ver jobs
{% if summary.failed_jobs %}

{{ summary.failed_jobs }} jobs aguardam investigação.

{% for job in summary.recent_failed_jobs %}{% endfor %}
Falhas recentes (máximo 10)
ClienteInstalaçãoJob ID / TipoErroCriadoFinalizado
{{ job.client or "-" }}{{ job.display_name or "-" }}{{ job.hostname or "hostname indisponível" }}{{ job.job_id }}{{ job.kind }}{{ job.error or "Sem detalhe de erro" }}{{ job.created_display }}{{ job.created_age }}{{ job.finished_display }}{{ job.finished_age }}
{% else %}

Nenhum job com falha.

{% endif %}
- +
+

FILA DE AÇÃO

Atenção necessária

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

Nenhum item requer atenção imediata.

{% endfor %} +
+
+
+

Jobs com falha

Ver jobs
+ {% if summary.failed_jobs %}

{{ summary.failed_jobs }} jobs aguardam investigação.

+
+ {% for job in summary.recent_failed_jobs %}{% endfor %} +
Falhas recentes (máximo 10)
Cliente / servidorTipo / erroFalhouAção
{{ job.client or "Cliente não identificado" }}{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}{{ job.kind_label }}{{ job.error or "Sem detalhe de erro" }}{{ job.finished_display }}{{ job.finished_age }}Ver job #{{ job.job_id }}
{% else %}

Nenhum job com falha.

{% endif %} +
+
{% endblock %} diff --git a/central/templates/jobs.html b/central/templates/jobs.html index 0e5a4b2..18f7a04 100644 --- a/central/templates/jobs.html +++ b/central/templates/jobs.html @@ -1,8 +1,22 @@ {% extends "base.html" %} {% block title %}Jobs | GTI Fiscal{% endblock %} {% block content %} -

EXECUÇÕES

Jobs globais

Acompanhe o que está em andamento e onde falhou.

-
-
{% for item in result["items"] %}{% else %}{% endfor %}
Execuções recentes
ClienteNome de exibiçãoHostnameJob IDTipoStatusCriadoFinalizadoDuraçãoErro
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "-" }}#{{ item.job_id }}Agent técnico {{ item.agent_id }}{{ item.kind }}Status: {{ item.status }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ item.error or "-" }}
Nenhum job encontrado para os filtros atuais.
- +

EXECUÇÕES

Jobs

Acompanhe execuções ativas e investigue falhas sem perder o histórico.

+
+ {% for value,label in [("PENDING","Pendentes"),("RUNNING","Executando"),("COMPLETED","Concluídos"),("FAILED","Falhos")] %}{{ label }}{{ status_counts[value] }}Ver jobs →{% endfor %} +
+
+ + + + + {% if job_id_filter %}{% endif %} + + {% if status_filter or search or kind_filter or job_id_filter or scope_filter=="all" %}Limpar filtros{% endif %} +
{% if status_filter or job_id_filter or scope_filter=="all" %}

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

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

Job #{{ selected.job_id }}

Voltar à lista

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

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

Erro completo

{{ selected.error or "Sem erro registrado" }}
Tipo técnico: {{ selected.kind }} · Status técnico: {{ selected.status }}
{% endif %} +
+{% for item in result["items"] %}{% else %}{% endfor %} +
Execuções recentes · {{ result.total }} resultados
Cliente / servidorTipo / jobStatusCriadoFinalizado / duraçãoErro / ação
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}{{ item.kind_label }}Job #{{ item.job_id }}{{ item.status_label }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}Ver job
Nenhum job encontrado. Consultar histórico
+ {% endblock %} diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index f936bcb..8bdfcd4 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -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&health=ONLINE"' in home + assert 'href="/blockers?scope=active&status=OPEN&field=NCM"' in home + assert 'href="/jobs?scope=active&status=FAILED"' in home + assert 'href="/agents?scope=active&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&status=FAILED&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 "DesconhecidaDesconhecida" 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 diff --git a/central/tests/test_operational_view_models.py b/central/tests/test_operational_view_models.py index 6482117..bb1a817 100644 --- a/central/tests/test_operational_view_models.py +++ b/central/tests/test_operational_view_models.py @@ -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(): diff --git a/docs/superpowers/plans/2026-09-24-fiscal-operational-ui.md b/docs/superpowers/plans/2026-09-24-fiscal-operational-ui.md new file mode 100644 index 0000000..26fea92 --- /dev/null +++ b/docs/superpowers/plans/2026-09-24-fiscal-operational-ui.md @@ -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 ``; 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 `` 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. diff --git a/docs/superpowers/specs/2026-09-24-fiscal-operational-ui-design.md b/docs/superpowers/specs/2026-09-24-fiscal-operational-ui-design.md new file mode 100644 index 0000000..dfdeae0 --- /dev/null +++ b/docs/superpowers/specs/2026-09-24-fiscal-operational-ui-design.md @@ -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 `` completos para Total, Online, Degraded, Offline, Failed, Unknown; destino `/agents?scope=active` com `health=` 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=`; 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=`. 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/`; 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 `` 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.