feat: operational panel UI - active-scope dashboard, drill-down cards, scoped Agents/Blockers/Jobs, PT-BR labels, responsive CSS
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+107
-31
@@ -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
|
||||
|
||||
@@ -99,3 +99,34 @@ def health_label(value) -> str:
|
||||
return "-"
|
||||
text = str(value)
|
||||
return _HEALTH_LABELS.get(text.upper(), text)
|
||||
|
||||
|
||||
def _human_label(value, labels):
|
||||
if not value:
|
||||
return "Não informado"
|
||||
text = str(value).upper()
|
||||
return labels.get(text, str(value).replace("_", " ").lower().capitalize())
|
||||
|
||||
|
||||
def fiscal_status_label(value):
|
||||
return _human_label(value, {"BLOCKED": "Bloqueado", "COMPLETE": "Completo", "INCOMPLETE": "Incompleto", "UNKNOWN": "Não informado"})
|
||||
|
||||
|
||||
def job_status_label(value):
|
||||
return _human_label(value, {"PENDING": "Pendente", "RUNNING": "Executando", "COMPLETED": "Concluído", "FAILED": "Falhou"})
|
||||
|
||||
|
||||
def job_kind_label(value):
|
||||
return _human_label(value, {"SYNC_INCREMENTAL": "Sincronização incremental", "INITIAL_SYNC": "Sincronização inicial", "REPROCESS_ALL": "Reprocessar tudo", "REPROCESS_PRODUCTS": "Reprocessar produtos"})
|
||||
|
||||
|
||||
def blocker_type_label(value):
|
||||
return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão"})
|
||||
|
||||
|
||||
def blocker_reason_label(value):
|
||||
return _human_label(value, {"INVALID_NCM": "NCM inválido", "INSUFFICIENT_EVIDENCE": "Evidência insuficiente", "MISSING_CONTEXT": "Contexto fiscal ausente", "NO_EVIDENCE": "Sem evidência"})
|
||||
|
||||
|
||||
def priority_label(value):
|
||||
return _human_label(value, {"CRITICAL": "Crítica", "HIGH": "Alta", "NORMAL": "Média", "MEDIUM": "Média", "LOW": "Baixa"})
|
||||
|
||||
+20
-10
@@ -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
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Agents | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Identidade do cliente primeiro; IDs técnicos ficam no detalhe.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents/releases" class="secondary">Releases</a></div></div>
|
||||
<div class="page-heading"><form class="filters" method="get"><label>Cliente, instalação ou hostname<input name="search" value="{{ search }}"></label><label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value in ["ONLINE","DEGRADED","OFFLINE","FAILED","UNKNOWN"] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ value|title }}</option>{% endfor %}</select></label><label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Nome de exibição</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label><button class="secondary" type="submit">Filtrar</button></form></div>
|
||||
<div class="panel table-wrap"><table><caption>Instalações monitoradas</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Saúde técnica</th><th>Status fiscal</th><th>Cobertura fiscal</th><th>Produtos</th><th>Versão</th><th>Último heartbeat</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b></a></td><td>{{ item.display_name or "Não informado" }}</td><td>{{ item.hostname or "Não informado" }}</td><td><span class="badge {{ item.technical_health|lower }}">Saúde técnica: {{ item.technical_health_label }}</span></td><td><span class="badge fiscal-{{ item.fiscal_status|lower }}">Status fiscal: {{ item.fiscal_status_label }}</span>{% if item.total_products is none %}<small>Cobertura ainda não informada (nenhum snapshot recebido)</small>{% else %}<small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados com segurança · {{ item.failed_products }} falhas · {{ item.unclassified_products }} não classificados</small>{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência: {{ item.coverage_overcount_products }} acima do total{% if item.coverage_invalid_counts %}; contagens negativas{% endif %}</small>{% endif %}{% endif %}</td><td>{% if item.fiscal_completion_percent is none %}<strong>–</strong><small>Não informado</small>{% else %}<strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ item.fiscally_complete }} / {{ item.total_products }} completos</small>{% endif %}</td><td>{% if item.total_products is none %}Não informado{% else %}{{ item.total_products }}<small>{{ item.failed_products }} falhas técnicas</small>{% endif %}</td><td>{{ item.agent_version_display }}</td><td title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.last_seen_age }}</small></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhuma instalação encontrada.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Clientes e servidores ativos. Use Histórico para consultar instalações inativas ou revogadas.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents/releases" class="secondary">Releases</a></div></div>
|
||||
<section class="panel filter-panel">
|
||||
<form class="filters agents-filters" method="get">
|
||||
{% if version_filter %}<input type="hidden" name="version" value="{{ version_filter }}">{% endif %}
|
||||
<label class="filter-search">Cliente, instalação ou hostname<input name="search" type="search" value="{{ search }}" placeholder="Buscar cliente ou servidor"></label>
|
||||
<label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value,label in [("ONLINE","Online"),("DEGRADED","Atenção"),("OFFLINE","Offline"),("FAILED","Falhou"),("UNKNOWN","Não informado")] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
|
||||
<label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Cliente / instalação</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label>
|
||||
<button class="secondary" type="submit">Filtrar</button>
|
||||
{% if search or health_filter or version_filter or scope_filter=="all" or sort!="display_name" %}<a class="secondary filter-clear" href="/agents?scope=active">Limpar filtros</a>{% endif %}
|
||||
</form>
|
||||
{% if health_filter or version_filter or scope_filter=="all" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if health_filter %}<span class="filter-chip">Saúde: {{ {"ONLINE":"Online","DEGRADED":"Atenção","OFFLINE":"Offline","FAILED":"Falhou","UNKNOWN":"Não informado"}[health_filter] }}</span>{% endif %}{% if version_filter %}<span class="filter-chip">Agents desatualizados</span>{% endif %}</p>{% endif %}
|
||||
</section>
|
||||
<div class="panel table-wrap agents-table-wrap"><table class="agents-table"><caption>Instalações monitoradas · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Saúde</th><th>Status fiscal</th><th>Cobertura</th><th>Produtos</th><th>Versão</th><th>Último contato</th></tr></thead><tbody>
|
||||
{% for item in result["items"] %}
|
||||
<tr class="navigable-row{% if not item.operational_active %} historical-row{% endif %}" data-href="/agents/{{ item.agent_id }}">
|
||||
<td data-label="Cliente / servidor"><a class="row-primary-link" href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Servidor não informado" }}</small></a>{% if item.display_name and item.hostname and item.display_name!=item.hostname %}<small>{{ item.display_name }}</small>{% endif %}{% if not item.operational_active %}<span class="badge unknown">Histórico / inativo</span>{% endif %}</td>
|
||||
<td data-label="Saúde"><span class="badge {{ item.technical_health|lower }}">{{ item.technical_health_label }}</span></td>
|
||||
<td data-label="Status fiscal"><span class="badge fiscal-{{ item.fiscal_status|lower }}">{{ item.fiscal_status_label }}</span>{% if item.total_products is not none %}<small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados{% if item.failed_products %} · {{ item.failed_products }} falhas{% endif %}</small>{% endif %}{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência no snapshot</small>{% endif %}</td>
|
||||
<td data-label="Cobertura">{% if item.fiscal_completion_percent is none %}Não informada{% else %}<strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong>{% endif %}</td>
|
||||
<td data-label="Produtos">{{ item.total_products if item.total_products is not none else "Não informado" }}</td>
|
||||
<td data-label="Versão">{{ item.agent_version or "Não informada" }}{% if item.version_drift=="OUTDATED" %}<small class="warning-text">Atualização disponível</small>{% endif %}</td>
|
||||
<td data-label="Último contato" title="{{ item.last_seen_at }}">{{ item.last_seen_age }}<small>{{ item.last_seen_display }}</small></td>
|
||||
</tr>
|
||||
{% else %}<tr><td colspan="7" class="empty-state">Nenhuma instalação encontrada para os filtros atuais. <a href="/agents?scope=all">Consultar histórico</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blockers | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers globais</h1><p class="muted">Problemas fiscais separados de falhas técnicas.</p></div></div>
|
||||
<section class="cards compact"><article class="metric"><span>Blockers abertos</span><strong>{{ summary.open_blockers }}</strong></article><article class="metric"><span>Produtos afetados</span><strong>{{ summary.affected_products }}</strong></article><article class="metric"><span>Clientes afetados</span><strong>{{ summary.affected_clients }}</strong></article><article class="metric"><span>Resultado filtrado</span><strong>{{ result.total }}</strong></article></section>
|
||||
<section class="panel"><form class="filters blocker-filters" method="get">{% if request.query_params.get('agent_id') %}<input type="hidden" name="agent_id" value="{{ request.query_params.get('agent_id') }}">{% endif %}{% if request.query_params.get('product_code') %}<input type="hidden" name="product_code" value="{{ request.query_params.get('product_code') }}">{% endif %}<label>Cliente<input name="client" value="{{ client_filter }}"></label><label>Instalação / Agent / Servidor<input name="installation" value="{{ installation_filter }}"></label><label>Campo<select name="field"><option value="">Todos</option>{% for value in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}"></label><label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label><label>Idade<input name="age" value="{{ age_filter }}" placeholder="ex.: 1d"></label><label>Produto ou busca<input name="search" value="{{ search }}"></label><button class="secondary" type="submit">Filtrar blockers</button></form></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers filtrados</h2></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b></div>{% endfor %}</div></section>
|
||||
<div class="panel table-wrap"><table><caption>Fila de blockers</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Produto</th><th>Campo</th><th>Tipo de blocker</th><th>Prioridade</th><th>Problema</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or "Cliente não identificado" }}</td><td>{{ item.display_name or "-" }}<small>{{ item.hostname or "hostname indisponível" }}</small></td><td><b>{{ item.product_code }}</b><small>{{ item.description or "" }}</small></td><td>{{ item.field or "OTHER" }}</td><td><span class="badge blocked">Fiscal: {{ item.blocker_type }}</span></td><td><span class="badge priority-{{ item.priority|lower }}">Prioridade: {{ item.priority }}</span></td><td>{{ item.reason }}<small>{{ item.age }}</small></td><td title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td><a class="secondary" href="{{ item.link }}">Ver detalhe</a></td></tr>{% else %}<tr><td colspan="9" class="empty-state">Nenhum blocker corresponde aos filtros.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de blockers">{% if result.page > 1 %}<a href="/blockers?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} blockers</span>{% if result.page * result.page_size < result.total %}<a href="/blockers?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers</h1><p class="muted">Problemas fiscais da operação ativa; histórico e resolvidos disponíveis no filtro.</p></div></div>
|
||||
<section class="cards compact" aria-label="Indicadores de blockers">
|
||||
<a class="metric metric-link" href="/blockers?scope={{ scope_filter }}&status=OPEN"><span>Blockers abertos</span><strong>{{ summary.open_blockers }}</strong><small>Ver abertos →</small></a>
|
||||
<div class="metric"><span>Produtos afetados</span><strong>{{ summary.affected_products }}</strong></div>
|
||||
<div class="metric"><span>Clientes afetados</span><strong>{{ summary.affected_clients }}</strong></div>
|
||||
<div class="metric{% if field_filter or client_filter or installation_filter or blocker_type_filter or reason_code_filter or search or age_filter or scope_filter=='all' or status_filter!='OPEN' %} metric-selected{% endif %}"><span>Resultado filtrado</span><strong>{{ result.total }}</strong></div>
|
||||
</section>
|
||||
<section class="panel filter-panel">
|
||||
<form class="filters blocker-filters" method="get">
|
||||
{% if request.query_params.get('agent_id') %}<input type="hidden" name="agent_id" value="{{ request.query_params.get('agent_id') }}">{% endif %}
|
||||
{% if request.query_params.get('product_code') %}<input type="hidden" name="product_code" value="{{ request.query_params.get('product_code') }}">{% endif %}
|
||||
{% if reason_code_filter %}<input type="hidden" name="reason_code" value="{{ reason_code_filter }}">{% endif %}
|
||||
<label>Cliente<input name="client" value="{{ client_filter }}"></label>
|
||||
<label>Instalação / servidor<input name="installation" value="{{ installation_filter }}"></label>
|
||||
<label>Campo<select name="field"><option value="">Todos</option>{% for value in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value if value!="OTHER" else "Outros" }}</option>{% endfor %}</select></label>
|
||||
<label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}" placeholder="Tipo técnico ou nome"></label>
|
||||
<label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label>
|
||||
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
|
||||
<label>Idade<input name="age" value="{{ age_filter }}" placeholder="ex.: 1d"></label>
|
||||
<label class="filter-search">Produto ou busca<input name="search" value="{{ search }}" type="search"></label>
|
||||
<button class="secondary" type="submit">Filtrar blockers</button>
|
||||
{% if field_filter or client_filter or installation_filter or blocker_type_filter or reason_code_filter or search or age_filter or scope_filter=="all" or status_filter!="OPEN" or request.query_params.get('agent_id') or request.query_params.get('product_code') %}<a class="secondary filter-clear" href="/blockers?scope=active">Limpar filtros</a>{% endif %}
|
||||
</form>
|
||||
{% if field_filter or reason_code_filter or scope_filter=="all" or status_filter!="OPEN" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if field_filter %}<span class="filter-chip">Campo: {{ field_filter }}</span>{% endif %}{% if reason_code_filter %}<span class="filter-chip">Motivo: {{ reason_code_filter }}</span>{% endif %}{% if status_filter!="OPEN" %}<span class="filter-chip">Status: {{ "Resolvidos" if status_filter=="RESOLVED" else "Todos" }}</span>{% endif %}</p>{% endif %}
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers filtrados</h2></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}{% if summary.blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?{{ field_query_base|e }}&field={{ key }}"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}{% endfor %}</div></section>
|
||||
<div class="panel table-wrap blockers-table-wrap"><table class="blockers-table"><caption>Fila de blockers · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Produto</th><th>Campo / tipo</th><th>Prioridade</th><th>Problema</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>
|
||||
{% for item in result["items"] %}<tr><td data-label="Cliente / servidor"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Instalação não informada" }}</small></td><td data-label="Produto"><a class="row-primary-link" href="{{ item.link }}"><b>{{ item.product_code }}</b><small>{{ item.description or "Sem descrição" }}</small></a></td><td data-label="Campo / tipo">{{ item.field or "Outros" }}<small>{{ item.blocker_type_label }}</small></td><td data-label="Prioridade"><span class="badge priority-{{ item.priority|lower }}">{{ item.priority_label }}</span></td><td data-label="Problema">{{ item.reason_label }}<small>{{ item.age }}</small></td><td data-label="Última ocorrência" title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td data-label="Ação"><a class="secondary" href="{{ item.link }}">Ver detalhe</a></td></tr>{% else %}<tr><td colspan="7" class="empty-state">Nenhum blocker corresponde aos filtros. <a href="/blockers?scope=all&status=ALL">Consultar histórico</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de blockers">{% if result.page > 1 %}<a href="/blockers?page={{ result.page - 1 }}{% if query_base %}&{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} blockers</span>{% if result.page * result.page_size < result.total %}<a href="/blockers?page={{ result.page + 1 }}{% if query_base %}&{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Resumo | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1><p class="muted">Fila operacional baseada no último snapshot recebido.</p></div><span class="live-dot">Atualizado pelo snapshot</span></div>
|
||||
<section class="cards" aria-label="Saúde dos agents">
|
||||
{% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("unknown","Unknown")] %}<article class="metric"><span>{{ label }}</span><strong>{{ summary[key] }}</strong></article>{% endfor %}
|
||||
<div class="page-heading">
|
||||
<div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1><p class="muted">Somente instalações ativas. Histórico permanece disponível nas listagens.</p></div>
|
||||
<span class="live-dot">Atualizado pelo snapshot</span>
|
||||
</div>
|
||||
<section class="cards" aria-label="Saúde dos servidores">
|
||||
{% for key,label,health in [("agents_total","Servidores",none),("online","Online","ONLINE"),("degraded","Atenção","DEGRADED"),("offline","Offline","OFFLINE"),("failed","Falhou","FAILED"),("unknown","Não informado","UNKNOWN")] %}
|
||||
<a class="metric metric-link" href="/agents?scope=active{% if health %}&health={{ health }}{% endif %}" aria-label="{{ label }}: {{ summary[key] }}. Ver servidores{% if health %} filtrados{% endif %}">
|
||||
<span>{{ label }}</span><strong>{{ summary[key] }}</strong><small>Ver servidores <span aria-hidden="true">→</span></small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<section class="split">
|
||||
<article class="panel"><div class="panel-title"><h2>Cobertura dos produtos</h2><a href="/agents">Ver instalações</a></div><div class="coverage">{% for key,label in [("total_products","Produtos avaliados"),("fiscally_complete","Completos"),("safe_blocked","Bloqueados com segurança"),("failed_products","Falhas técnicas"),("unclassified_products","Não classificados") ] %}<div><strong>{{ summary[key] }}</strong><span>{{ label }}</span></div>{% endfor %}</div>{% if summary.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada: total = completos + bloqueados com segurança + falhas técnicas + não classificados.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.</p>{% endif %}<div class="registry-summary"><a href="/blockers">Produtos com blockers abertos: <strong>{{ summary.safe_blocked_products }}</strong></a><span>Blockers abertos: <strong>{{ summary.open_blockers }}</strong></span></div></article>
|
||||
<article class="panel"><div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers">Abrir fila</a></div>{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b></div>{% endfor %}</article>
|
||||
<section class="split overview-split">
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Cobertura dos produtos</h2><a href="/agents?scope=active">Ver instalações</a></div>
|
||||
<div class="coverage">
|
||||
{% for key,label,url in [("total_products","Produtos avaliados","/agents?scope=active"),("fiscally_complete","Completos","/agents?scope=active&sort=worst_coverage"),("safe_blocked","Bloqueados com segurança","/blockers?scope=active&status=OPEN"),("failed_products","Falhas técnicas","/agents?scope=active&health=FAILED"),("unclassified_products","Não classificados","/agents?scope=active&sort=worst_coverage")] %}
|
||||
<a class="coverage-item" href="{{ url|safe }}" aria-label="{{ label }}: {{ summary[key] }}. Abrir listagem"><strong>{{ summary[key] }}</strong><span>{{ label }}</span></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if summary.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada: total = completos + bloqueados + falhas + não classificados.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.</p>{% endif %}
|
||||
<div class="registry-summary"><a href="/blockers?scope=active&status=OPEN">Produtos com blockers abertos: <strong>{{ summary.safe_blocked_products }}</strong></a><span>Blockers abertos: <strong>{{ summary.open_blockers }}</strong></span></div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&status=OPEN">Abrir fila</a></div>
|
||||
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
|
||||
{% if summary.open_blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?scope=active&status=OPEN&field={{ key }}" aria-label="{{ key }}: {{ summary.open_blocker_breakdown[key] }} blockers abertos"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>
|
||||
{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}
|
||||
{% endfor %}
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel attention"><div class="panel-title"><div><p class="eyebrow">FILA DE AÇÃO</p><h2>ATENÇÃO NECESSÁRIA</h2></div><a href="/blockers">Ver todos</a></div>{% for item in summary.attention_items[:10] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.client or "Cliente não identificado" }}</b> · {{ item.display_name or "Instalação" }}<small>{{ item.hostname or "hostname indisponível" }} · {{ item.problem }}</small></span><em class="badge {{ item.technical_health|lower }}">{{ item.age }}</em></a>{% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}</section>
|
||||
<section class="split">
|
||||
<article class="panel"><div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?status=FAILED">Ver jobs</a></div>{% if summary.failed_jobs %}<p class="metric-inline"><strong>{{ summary.failed_jobs }}</strong> jobs aguardam investigação.</p><div class="table-wrap"><table><caption>Falhas recentes (máximo 10)</caption><thead><tr><th>Cliente</th><th>Instalação</th><th>Job ID / Tipo</th><th>Erro</th><th>Criado</th><th>Finalizado</th></tr></thead><tbody>{% for job in summary.recent_failed_jobs %}<tr><td>{{ job.client or "-" }}</td><td>{{ job.display_name or "-" }}<small>{{ job.hostname or "hostname indisponível" }}</small></td><td><a href="{{ job.link }}">{{ job.job_id }}</a><small>{{ job.kind }}</small></td><td>{{ job.error or "Sem detalhe de erro" }}</td><td title="{{ job.created_at }}">{{ job.created_display }}<small>{{ job.created_age }}</small></td><td title="{{ job.finished_at }}">{{ job.finished_display }}<small>{{ job.finished_age }}</small></td></tr>{% endfor %}</tbody></table></div>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}</article>
|
||||
<article class="panel"><div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?sort=worst_coverage">Ver agents</a></div>{% for item in summary.outdated_agents[:10] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client }}</b> · {{ item.display_name }}<small>{{ item.hostname or "hostname indisponível" }} · {{ item.agent_version or "versão desconhecida" }}</small></span><em class="badge warning">Desatualizado</em></a>{% else %}<p class="empty-state">Nenhum agent desatualizado.</p>{% endfor %}</article>
|
||||
<section class="panel attention">
|
||||
<div class="panel-title"><div><p class="eyebrow">FILA DE AÇÃO</p><h2>Atenção necessária</h2></div><a href="/blockers?scope=active&status=OPEN">Ver todos</a></div>
|
||||
{% for item in summary.attention_items %}
|
||||
<a class="list-row attention-row" href="{{ item.link }}"><span><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Servidor sem nome" }} · {{ item.problem }}</small>{% if item.group_count %}<small>{{ item.products_affected }} produtos afetados · {{ item.group_count }} blockers abertos · Ver blockers →</small>{% endif %}</span><em class="badge {{ 'priority-' ~ item.priority|lower if item.priority else item.technical_health|lower }}">{{ item.age }}</em></a>
|
||||
{% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}
|
||||
</section>
|
||||
<section class="split jobs-overview">
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?scope=active&status=FAILED">Ver jobs</a></div>
|
||||
{% if summary.failed_jobs %}<p class="metric-inline"><strong>{{ summary.failed_jobs }}</strong> jobs aguardam investigação.</p>
|
||||
<div class="table-wrap"><table class="compact-table"><caption>Falhas recentes (máximo 10)</caption><thead><tr><th>Cliente / servidor</th><th>Tipo / erro</th><th>Falhou</th><th>Ação</th></tr></thead><tbody>
|
||||
{% for job in summary.recent_failed_jobs %}<tr><td><b>{{ job.client or "Cliente não identificado" }}</b><small>{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}</small></td><td>{{ job.kind_label }}<small class="error-text">{{ job.error or "Sem detalhe de erro" }}</small></td><td title="{{ job.finished_at }}">{{ job.finished_display }}<small>{{ job.finished_age }}</small></td><td><a href="/jobs?scope=active&status=FAILED&job_id={{ job.job_id }}">Ver job #{{ job.job_id }}</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?scope=active&version=outdated">Ver todos</a></div>
|
||||
{% for item in summary.outdated_agents[:5] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small><small>{{ item.agent_version or "Versão não informada" }} → atualização disponível</small></span><em class="badge warning">Desatualizado</em></a>{% else %}<p class="empty-state">Nenhum Agent desatualizado.</p>{% endfor %}
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Jobs | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs globais</h1><p class="muted">Acompanhe o que está em andamento e onde falhou.</p></div></div>
|
||||
<section class="panel"><form class="filters" method="get"><label>Status<select name="status"><option value="">Todos</option>{% for value in ["PENDING","RUNNING","COMPLETED","FAILED"] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Cliente, instalação ou busca<input name="search" value="{{ search }}"></label><label>Tipo<input name="kind" value="{{ kind_filter }}"></label><button class="secondary" type="submit">Filtrar jobs</button></form></section>
|
||||
<div class="panel table-wrap"><table><caption>Execuções recentes</caption><thead><tr><th>Cliente</th><th>Nome de exibição</th><th>Hostname</th><th>Job ID</th><th>Tipo</th><th>Status</th><th>Criado</th><th>Finalizado</th><th>Duração</th><th>Erro</th></tr></thead><tbody>{% for item in result["items"] %}<tr><td>{{ item.client or "Cliente não identificado" }}</td><td>{{ item.display_name or "-" }}</td><td>{{ item.hostname or "-" }}</td><td>#{{ item.job_id }}<small>Agent técnico {{ item.agent_id }}</small></td><td>{{ item.kind }}</td><td><span class="badge status-{{ item.status|lower }}">Status: {{ item.status }}</span></td><td title="{{ item.created_at }}">{{ item.created_display }}</td><td title="{{ item.finished_at }}">{{ item.finished_display }}</td><td>{{ item.duration }}</td><td class="error-text">{{ item.error or "-" }}</td></tr>{% else %}<tr><td colspan="10" class="empty-state">Nenhum job encontrado para os filtros atuais.</td></tr>{% endfor %}</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de jobs">{% if result.page > 1 %}<a href="/jobs?page={{ result.page - 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} jobs</span>{% if result.page * result.page_size < result.total %}<a href="/jobs?page={{ result.page + 1 }}{% if query_base %}&{{ query_base }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs</h1><p class="muted">Acompanhe execuções ativas e investigue falhas sem perder o histórico.</p></div></div>
|
||||
<section class="cards compact jobs-kpis" aria-label="Jobs por status">
|
||||
{% for value,label in [("PENDING","Pendentes"),("RUNNING","Executando"),("COMPLETED","Concluídos"),("FAILED","Falhos")] %}<a class="metric metric-link{% if status_filter==value %} metric-selected{% endif %}" href="/jobs?scope={{ scope_filter }}&status={{ value }}"><span>{{ label }}</span><strong>{{ status_counts[value] }}</strong><small>Ver jobs →</small></a>{% endfor %}
|
||||
</section>
|
||||
<section class="panel filter-panel"><form class="filters" method="get">
|
||||
<label>Status<select name="status"><option value="">Todos</option>{% for value,label in [("PENDING","Pendente"),("RUNNING","Executando"),("COMPLETED","Concluído"),("FAILED","Falhou")] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<label class="filter-search">Cliente, instalação ou busca<input name="search" value="{{ search }}" type="search"></label>
|
||||
<label>Tipo<input name="kind" value="{{ kind_filter }}" placeholder="Ex.: SYNC_INCREMENTAL"></label>
|
||||
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
|
||||
{% if job_id_filter %}<input type="hidden" name="job_id" value="{{ job_id_filter }}">{% endif %}
|
||||
<button class="secondary" type="submit">Filtrar jobs</button>
|
||||
{% if status_filter or search or kind_filter or job_id_filter or scope_filter=="all" %}<a class="secondary filter-clear" href="/jobs?scope=active">Limpar filtros</a>{% endif %}
|
||||
</form>{% if status_filter or job_id_filter or scope_filter=="all" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if status_filter %}<span class="filter-chip">Status: {{ {"PENDING":"Pendente","RUNNING":"Executando","COMPLETED":"Concluído","FAILED":"Falhou"}[status_filter] }}</span>{% endif %}{% if job_id_filter %}<span class="filter-chip">Job #{{ job_id_filter }}</span>{% endif %}</p>{% endif %}</section>
|
||||
{% if job_id_filter and result["items"] %}{% set selected = result["items"][0] %}<section class="panel job-detail"><div class="panel-title"><h2>Job #{{ selected.job_id }}</h2><a href="/jobs?scope={{ scope_filter }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Voltar à lista</a></div><p><b>{{ selected.client or "Cliente não identificado" }}</b> · {{ selected.display_name or "Instalação" }} · {{ selected.hostname or "Servidor não informado" }}</p><p>{{ selected.kind_label }} <span class="badge status-{{ selected.status|lower }}">{{ selected.status_label }}</span></p><p><b>Erro completo</b></p><pre class="job-error-full">{{ selected.error or "Sem erro registrado" }}</pre><small>Tipo técnico: {{ selected.kind }} · Status técnico: {{ selected.status }}</small></section>{% endif %}
|
||||
<div class="panel table-wrap jobs-table-wrap"><table class="jobs-table"><caption>Execuções recentes · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Tipo / job</th><th>Status</th><th>Criado</th><th>Finalizado / duração</th><th>Erro / ação</th></tr></thead><tbody>
|
||||
{% for item in result["items"] %}<tr class="{% if item.status=='FAILED' %}job-failed-row{% endif %}"><td data-label="Cliente / servidor"><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small></a></td><td data-label="Tipo / job">{{ item.kind_label }}<small>Job #{{ item.job_id }}</small></td><td data-label="Status"><span class="badge status-{{ item.status|lower }}">{{ item.status_label }}</span></td><td data-label="Criado" title="{{ item.created_at }}">{{ item.created_display }}</td><td data-label="Finalizado / duração" title="{{ item.finished_at }}">{{ item.finished_display }}<small>{{ item.duration }}</small></td><td data-label="Erro / ação" class="{% if item.status=='FAILED' %}error-text{% endif %}"><span class="error-summary">{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}</span><a href="/jobs?scope={{ scope_filter }}&job_id={{ item.job_id }}">Ver job</a></td></tr>{% else %}<tr><td colspan="6" class="empty-state">Nenhum job encontrado. <a href="/jobs?scope=all">Consultar histórico</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
<nav class="pagination" aria-label="Paginação de jobs">{% if result.page > 1 %}<a href="/jobs?page={{ result.page - 1 }}{% if query_base %}&{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} jobs</span>{% if result.page * result.page_size < result.total %}<a href="/jobs?page={{ result.page + 1 }}{% if query_base %}&{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 "<td>Desconhecida<small>Desconhecida</small></td>" not in response.text
|
||||
|
||||
|
||||
@@ -254,7 +313,7 @@ def test_jobs_render_client_identity_duration_and_preserve_filters():
|
||||
response = client.get("/jobs?status=RUNNING&search=Acme")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente", "Nome de exibição", "Hostname", "Job ID", "Tipo", "Status", "Criado", "Finalizado", "Duração", "Erro"):
|
||||
for label in ("Cliente / servidor", "Tipo / job", "Status", "Criado", "Finalizado / duração", "Erro / ação"):
|
||||
assert label in response.text
|
||||
assert "Acme" in response.text
|
||||
assert "srv-01" in response.text
|
||||
@@ -300,10 +359,10 @@ def test_blockers_render_visible_filters_summary_priority_and_detail_link():
|
||||
response = client.get("/blockers?client=Acme&installation=srv-01&field=CFOP&blocker_type=BLOCKED_MISSING_EVIDENCE&status=OPEN&age=0h&search=P-001")
|
||||
|
||||
assert response.status_code == 200
|
||||
for label in ("Cliente", "Instalação / Agent / Servidor", "Campo", "Tipo de blocker", "Status", "Idade", "Produto ou busca", "Blockers abertos", "Produtos afetados", "Clientes afetados", "CFOP", "ST", "ICMS", "CSOSN", "NCM", "OTHER", "Prioridade"):
|
||||
for label in ("Cliente", "Instalação / servidor", "Campo", "Tipo de blocker", "Status", "Idade", "Produto ou busca", "Blockers abertos", "Produtos afetados", "Clientes afetados", "CFOP", "ST", "ICMS", "CSOSN", "NCM", "Outros", "Prioridade"):
|
||||
assert label in response.text
|
||||
assert f"/blockers/{blocker_id}" in response.text
|
||||
assert "CRITICAL" in response.text or "HIGH" in response.text
|
||||
assert "Alta" in response.text and "Evidência fiscal ausente" in response.text
|
||||
assert 'value="Acme"' in response.text
|
||||
assert 'value="BLOCKED_MISSING_EVIDENCE"' in response.text
|
||||
assert "window.confirm" not in response.text
|
||||
|
||||
@@ -5,15 +5,27 @@ from sqlalchemy.dialects import postgresql
|
||||
|
||||
from central.app.audit import record_audit
|
||||
from central.app.blocked_products import upsert_blocker
|
||||
from central.app.control_plane import _blocker_breakdown_query, dashboard_summary, list_blockers, list_installations, list_jobs
|
||||
from central.app.control_plane import _blocker_breakdown_query, blocker_filter_summary, dashboard_summary, jobs_status_summary, list_blockers, list_installations, list_jobs
|
||||
from central.app.config import settings
|
||||
from central.app.events import record_event
|
||||
from central.app.models import Agent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant
|
||||
from central.app.panel_presentation import blocker_reason_label, blocker_type_label, fiscal_status_label, job_kind_label, job_status_label, priority_label
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 22, 19, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_operator_labels_are_ptbr_without_changing_internal_codes():
|
||||
assert job_status_label("FAILED") == "Falhou"
|
||||
assert job_status_label("RUNNING") == "Executando"
|
||||
assert job_kind_label("SYNC_INCREMENTAL") == "Sincronização incremental"
|
||||
assert blocker_type_label("BLOCKED_MISSING_EVIDENCE") == "Evidência fiscal ausente"
|
||||
assert blocker_reason_label("INVALID_NCM") == "NCM inválido"
|
||||
assert priority_label("HIGH") == "Alta"
|
||||
assert fiscal_status_label("BLOCKED") == "Bloqueado"
|
||||
|
||||
|
||||
def _installation(session, index, *, health="ONLINE", coverage=80, blocked=0, version="1.0.0rc1"):
|
||||
tenant = Tenant(name=f"Client {index}")
|
||||
session.add(tenant)
|
||||
@@ -80,14 +92,14 @@ def _blocker(session, tenant, *, product, field="CFOP", blocker_type="BLOCKED_MI
|
||||
def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_products():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
first = _installation(session, 1, health="ONLINE", blocked=2)[0]
|
||||
second = _installation(session, 2, health="DEGRADED")[0]
|
||||
first, _, first_installation, _ = _installation(session, 1, health="ONLINE", blocked=2)
|
||||
second, _, second_installation, _ = _installation(session, 2, health="DEGRADED")
|
||||
_installation(session, 3, health="OFFLINE")
|
||||
_installation(session, 4, health="FAILED")
|
||||
_installation(session, 5, health="UNKNOWN")
|
||||
_blocker(session, first, product="P1", field="CFOP", index=1)
|
||||
_blocker(session, first, product="P1", field="NCM", index=2)
|
||||
_blocker(session, second, product="P2", field="ICMS", index=3)
|
||||
_blocker(session, first, product="P1", field="CFOP", index=1, installation_id=first_installation.id)
|
||||
_blocker(session, first, product="P1", field="NCM", index=2, installation_id=first_installation.id)
|
||||
_blocker(session, second, product="P2", field="ICMS", index=3, installation_id=second_installation.id)
|
||||
session.commit()
|
||||
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
@@ -100,6 +112,173 @@ def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_produc
|
||||
assert summary["online"] == 1 and summary["safe_blocked"] == 2
|
||||
|
||||
|
||||
def test_installation_inventory_active_scope_excludes_inactive_and_revoked_but_keeps_history():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
active = _installation(session, 1)[2]
|
||||
inactive_tenant, _, _, _ = _installation(session, 2)
|
||||
inactive_tenant.active = False
|
||||
_, disabled_agent, _, _ = _installation(session, 3)
|
||||
disabled_agent.enabled = False
|
||||
tenant, revoked_agent, revoked, _ = _installation(session, 4, health="UNKNOWN")
|
||||
_, _, paused, _ = _installation(session, 5)
|
||||
paused.processing_enabled = False
|
||||
release = AgentRelease(version="test-inventory", platform="windows", architecture="x64", filename="test.exe", storage_path="test.exe", size_bytes=1, sha256="x" * 64)
|
||||
session.add(release)
|
||||
session.flush()
|
||||
session.add(AgentEnrollment(enrollment_uuid=revoked.installation_id, tenant_id=tenant.id, agent_id=revoked_agent.id, display_name="Histórico", release_id=release.id, status="REVOKED"))
|
||||
session.commit()
|
||||
|
||||
current = list_installations(session, page_size=10, now=NOW)
|
||||
historical = list_installations(session, page_size=10, scope="all", now=NOW)
|
||||
unknown = list_installations(session, health="UNKNOWN", now=NOW)
|
||||
|
||||
assert {row["installation_id"] for row in current["items"]} == {active.installation_id, paused.installation_id}
|
||||
assert current["total"] == 2
|
||||
assert historical["total"] == 5
|
||||
assert unknown["total"] == 0
|
||||
assert {row["installation_id"]: row["operational_active"] for row in historical["items"]}[revoked.installation_id] is False
|
||||
|
||||
|
||||
def test_outdated_agent_filter_returns_only_outdated_active_installations():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
_, _, outdated, _ = _installation(session, 1, version="0.9.0")
|
||||
_, _, _, current_snapshot = _installation(session, 2, version=settings.agent_server_version)
|
||||
current_snapshot.version_drift = "CURRENT"
|
||||
_, disabled, _, _ = _installation(session, 3, version="0.9.0")
|
||||
disabled.enabled = False
|
||||
session.commit()
|
||||
|
||||
result = list_installations(session, version="outdated", now=NOW)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert result["items"][0]["installation_id"] == outdated.installation_id
|
||||
|
||||
|
||||
def test_dashboard_excludes_inactive_history_and_groups_repeated_blockers():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, agent, installation, snapshot = _installation(session, 1, health="ONLINE", coverage=74, blocked=26, version=settings.agent_server_version)
|
||||
snapshot.version_drift = "CURRENT"
|
||||
for index in range(26):
|
||||
_blocker(session, tenant, product=f"P{index:03}", field="NCM", index=index, installation_id=installation.id)
|
||||
session.add(Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="FAILED", error="erro real", created_at=NOW))
|
||||
|
||||
old_tenant, old_agent, old_installation, _ = _installation(session, 2, health="UNKNOWN", version="0.9.0")
|
||||
_blocker(session, old_tenant, product="HISTORICAL", field="CFOP", index=80, installation_id=old_installation.id)
|
||||
session.add(Job(agent_id=old_agent.id, kind="REPROCESS_ALL", status="FAILED", error="erro histórico", created_at=NOW))
|
||||
release = AgentRelease(version="test-dashboard", platform="windows", architecture="x64", filename="test.exe", storage_path="test.exe", size_bytes=1, sha256="y" * 64)
|
||||
session.add(release)
|
||||
session.flush()
|
||||
session.add(AgentEnrollment(enrollment_uuid=old_installation.installation_id, tenant_id=old_tenant.id, agent_id=old_agent.id, display_name="Encerrada", release_id=release.id, status="REVOKED"))
|
||||
inactive_tenant, _, _, _ = _installation(session, 3, health="OFFLINE")
|
||||
inactive_tenant.active = False
|
||||
_, disabled_agent, _, _ = _installation(session, 4, health="FAILED")
|
||||
disabled_agent.enabled = False
|
||||
session.commit()
|
||||
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
|
||||
assert summary["agents_total"] == 1
|
||||
assert summary["health_counts"] == {"ONLINE": 1, "DEGRADED": 0, "OFFLINE": 0, "FAILED": 0, "UNKNOWN": 0}
|
||||
assert summary["total_products"] == 100
|
||||
assert summary["safe_blocked"] == 26
|
||||
assert summary["open_blockers"] == 26
|
||||
assert summary["safe_blocked_products"] == 26
|
||||
assert summary["open_blocker_breakdown"]["NCM"] == 26
|
||||
assert summary["open_blocker_breakdown"]["CFOP"] == 0
|
||||
assert summary["failed_jobs"] == 1
|
||||
fiscal_groups = [item for item in summary["attention_items"] if item.get("field") == "NCM"]
|
||||
assert len(fiscal_groups) == 1
|
||||
assert fiscal_groups[0]["group_count"] == 26
|
||||
assert fiscal_groups[0]["products_affected"] == 26
|
||||
assert fiscal_groups[0]["link"].startswith("/blockers?")
|
||||
assert len(summary["attention_items"]) <= 10
|
||||
assert all(item["client"] != "Client 2" for item in summary["attention_items"])
|
||||
|
||||
|
||||
def test_blockers_and_jobs_keep_revoked_history_out_of_active_scope():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, agent, installation, _ = _installation(session, 1)
|
||||
current = _blocker(session, tenant, product="CURRENT", field="NCM", index=1, installation_id=installation.id)
|
||||
current.reason_code = "INVALID_NCM"
|
||||
resolved = _blocker(session, tenant, product="RESOLVED", field="NCM", index=2, installation_id=installation.id)
|
||||
resolved.resolved_at = NOW
|
||||
session.add(Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="FAILED", created_at=NOW, error="current"))
|
||||
|
||||
old_tenant, old_agent, old_installation, _ = _installation(session, 2)
|
||||
_blocker(session, old_tenant, product="OLD", field="NCM", index=3, installation_id=old_installation.id)
|
||||
session.add(Job(agent_id=old_agent.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW, error="historical"))
|
||||
old_agent.enabled = False
|
||||
session.commit()
|
||||
|
||||
assert list_blockers(session, field="NCM")["total"] == 1
|
||||
assert list_blockers(session, field="NCM", reason_code="INVALID_NCM")["items"][0]["id"] == current.id
|
||||
assert list_blockers(session, field="NCM", scope="all")["total"] == 2
|
||||
assert list_blockers(session, status="RESOLVED", scope="all")["items"][0]["id"] == resolved.id
|
||||
assert blocker_filter_summary(session)["open_blockers"] == 1
|
||||
assert list_jobs(session, status="FAILED")["total"] == 1
|
||||
assert list_jobs(session, status="FAILED", scope="all")["total"] == 2
|
||||
|
||||
|
||||
def test_jobs_status_kpis_count_active_jobs_by_status_in_one_group():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
_, agent, _, _ = _installation(session, 1)
|
||||
_, disabled, _, _ = _installation(session, 2)
|
||||
disabled.enabled = False
|
||||
for status in ("PENDING", "RUNNING", "COMPLETED", "FAILED"):
|
||||
session.add(Job(agent_id=agent.id, kind="SYNC_INCREMENTAL", status=status, created_at=NOW))
|
||||
session.add(Job(agent_id=disabled.id, kind="REPROCESS_ALL", status="FAILED", created_at=NOW))
|
||||
session.commit()
|
||||
|
||||
assert jobs_status_summary(session) == {"PENDING": 1, "RUNNING": 1, "COMPLETED": 1, "FAILED": 1}
|
||||
assert jobs_status_summary(session, scope="all")["FAILED"] == 2
|
||||
|
||||
|
||||
def test_attention_groups_rank_high_priority_before_limiting_recent_low_priority():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1, version=settings.agent_server_version)
|
||||
high = _blocker(session, tenant, product="IMPORTANT", field="NCM", index=90, installation_id=installation.id)
|
||||
high.last_seen_at = NOW - timedelta(days=3)
|
||||
for index in range(12):
|
||||
low = _blocker(session, tenant, product=f"LOW-{index}", field="OTHER", blocker_type="UNMAPPED", index=index, installation_id=installation.id)
|
||||
low.reason_code = f"OTHER_{index}"
|
||||
low.last_seen_at = NOW - timedelta(minutes=index)
|
||||
session.commit()
|
||||
|
||||
attention = dashboard_summary(session, now=NOW)["attention_items"]
|
||||
|
||||
assert len(attention) <= 10
|
||||
assert any(item.get("field") == "NCM" for item in attention)
|
||||
|
||||
|
||||
def test_dashboard_query_count_stays_bounded_with_many_blockers():
|
||||
factory = setup_db()
|
||||
with factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1, version=settings.agent_server_version)
|
||||
for index in range(130):
|
||||
_blocker(session, tenant, product=f"P-{index:03}", field="NCM", index=index, installation_id=installation.id)
|
||||
session.commit()
|
||||
statements = []
|
||||
|
||||
def count_statement(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(session.bind, "before_cursor_execute", count_statement)
|
||||
try:
|
||||
summary = dashboard_summary(session, now=NOW)
|
||||
finally:
|
||||
event.remove(session.bind, "before_cursor_execute", count_statement)
|
||||
|
||||
assert summary["open_blockers"] == 130
|
||||
assert len(statements) <= 18
|
||||
assert not any("SELECT" in sql.upper() and "LIMIT 130" in sql.upper() for sql in statements)
|
||||
|
||||
|
||||
def test_snapshot_coverage_exposes_unclassified_residual_and_overcount():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
@@ -290,7 +469,7 @@ def test_legacy_tenant_blocker_has_no_fabricated_installation_context():
|
||||
with session_factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1)
|
||||
row = _blocker(session, tenant, product="LEGACY", index=99)
|
||||
result = list_blockers(session, tenant_id=tenant.id)
|
||||
result = list_blockers(session, tenant_id=tenant.id, scope="all")
|
||||
|
||||
assert result["items"][0]["id"] == row.id
|
||||
assert result["items"][0]["installation_id"] is None
|
||||
@@ -370,11 +549,11 @@ def test_lowercase_required_blocker_field_reaches_attention_candidates():
|
||||
session_factory = setup_db()
|
||||
with session_factory() as session:
|
||||
tenant, _, installation, _ = _installation(session, 1)
|
||||
blocker = _blocker(session, tenant, product="LOWER", field="cfop", index=120, installation_id=installation.id)
|
||||
_blocker(session, tenant, product="LOWER", field="cfop", index=120, installation_id=installation.id)
|
||||
|
||||
attention = dashboard_summary(session, now=NOW)["attention_items"]
|
||||
|
||||
assert any(item["link"] == f"/blockers/{blocker.id}" for item in attention)
|
||||
assert any(item.get("field") == "CFOP" and item["link"].startswith("/blockers?scope=active") for item in attention)
|
||||
|
||||
|
||||
def test_blocker_breakdown_query_groups_on_same_postgres_expression():
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# GTI Fiscal Operational UI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Work inline; the operator authorized proceeding without additional design confirmation.
|
||||
|
||||
**Goal:** Make Resumo, Agents, Blockers and Jobs a fast, accessible, PT-BR operational workflow where each important aggregate reaches a filtered detail in one click, with revoked/inactive history excluded from live indicators.
|
||||
|
||||
**Architecture:** A single SQLAlchemy active-installation predicate is shared by dashboard aggregates and paged list queries. Small presenter helpers translate labels; Jinja templates render native links and bounded summaries using Gerenciador tokens. Routes preserve GET querystrings and offer explicit active/all history scopes.
|
||||
|
||||
**Tech Stack:** Existing FastAPI, SQLAlchemy, Jinja, CSS tokens, minimal vanilla JS, pytest, Playwright, `scripts/deploy_hub.ps1`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `OPERATIONALLY_ACTIVE`: `Tenant.active IS TRUE AND Agent.enabled IS TRUE`, excluding linked `AgentEnrollment` status `REVOKED`, `FAILED`, `EXPIRED`; no inference from client/installation names.
|
||||
- `Installation.processing_enabled=false` means paused jobs, not inactive server; keep it in health counts. Agents without linked enrollment remain active if Tenant and Agent are active.
|
||||
- Preserve history via `scope=all`; default `scope=active` across operational lists. Existing resolved blocker/history URLs remain reachable.
|
||||
- Preserve GTI Gerenciador client combobox, SSO, themes, HTTPS, Agent/runtime, fiscal engine and `FIREBIRD_WRITES=0`; no deletion of historical data.
|
||||
- All filtering and pagination server-side (50/page); bounded SQL aggregates before LIMIT 10; no N+1 or client-side loading of products.
|
||||
- Do not commit unless explicitly asked. Deploy exclusively through `scripts/deploy_hub.ps1`; no ad-hoc SSH/deploy script.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared active scope and paginated inventory
|
||||
|
||||
**Files:** `central/app/control_plane.py`, `central/tests/test_operational_view_models.py`, `central/app/web.py`.
|
||||
|
||||
**Interfaces:** `operational_installation_predicate()` yields a SQLAlchemy condition on Tenant/Agent/Installation, using `~exists(select(AgentEnrollment.id).where(AgentEnrollment.agent_id == Agent.id, AgentEnrollment.status.in_(["REVOKED", "FAILED", "EXPIRED"])))`. `list_installations(..., scope="active", version=None)` accepts `scope=active|all`; `version="outdated"` restricts to outdated versions. Web route validates scope/health/sort/version before querying and passes `query_base` with these filters.
|
||||
|
||||
- [ ] RED: seed active, Tenant.active=False, Agent.enabled=False, ENROLLED and REVOKED linked cases and assert default `list_installations` contains only active ids; `scope="all"` contains every id and preserves `health` filtering. Assert a `processing_enabled=False` active installation remains visible. Run `python -m pytest central/tests/test_operational_view_models.py -q` and confirm missing scope behavior fails.
|
||||
- [ ] GREEN: add `AgentEnrollment` import/predicate, apply same predicate before both paged list and count; support `version="outdated"` via existing drift expression. Preserve SQL `LIMIT/OFFSET` and existing joins. Re-run focused test.
|
||||
- [ ] RED/GREEN: web TestClient `/agents?scope=active&health=UNKNOWN` and `/agents?scope=all` reflect selected filter, pagination links preserve scope; implement route validation and query_base, rerun `python -m pytest central/tests/test_control_panel_pages.py -q`.
|
||||
|
||||
### Task 2: Operational dashboard aggregates and grouped attention
|
||||
|
||||
**Files:** `central/app/control_plane.py`, `central/tests/test_operational_view_models.py`.
|
||||
|
||||
**Interfaces:** `dashboard_summary` returns same keys as today plus grouped `attention_items` (<=10) with `group_count`, `products_affected`, `link`, `priority` and client/server identity. Blocker totals and failed-job counts only include open/current records attributed to active installations. Limit candidate groups after SQL `GROUP BY (installation_id, field, blocker_type, reason_code)`; lower-case fields normalized to upper-case for breakdown. Dashboard does not load all blockers/products.
|
||||
|
||||
- [ ] RED: seed revoked Agent enrollment with UNKNOWN snapshot, inactive tenant, disabled Agent, 26 NCM blockers on one active installation plus historical resolved blockers; assert active health/coverage/blocker/job aggregates exclude inactive rows, NCM becomes one attention group with 26 products, and `len(attention_items) <= 10`. Run focused operational test and observe incorrect counts/duplicate rows.
|
||||
- [ ] GREEN: reuse Task 1 predicate for health/snapshots/outdated/failed jobs. For blocker aggregation join Tenant→Installation→Agent, filter active/open; count distinct `(tenant_id, product_code)` and compute grouped fields before `.limit(10)`, then order technical alerts before fiscal groups and outdated. Keep a bounded fixed number of SQL queries independent of product volume. Re-run focused test.
|
||||
- [ ] Measure number of executed SQL statements with SQLAlchemy `before_cursor_execute` for 1 vs 26 blockers; assert bounded query count and no statement per blocker. Compare SQL GROUP BY query on SQLite and PostgreSQL dialect compilation; inspect indexes from `blocked_products(installation_id,resolved_at,field)` and `jobs(agent_id,status)` before adding migration.
|
||||
|
||||
### Task 3: Scoped Blockers and Jobs data models
|
||||
|
||||
**Files:** `central/app/control_plane.py`, `central/app/web.py`, `central/tests/test_operational_view_models.py`, `central/tests/test_control_panel_pages.py`.
|
||||
|
||||
**Interfaces:** `list_blockers`/`blocker_filter_summary(..., scope="active")` and `list_jobs(..., scope="active")` reuse Task 1 active condition; `jobs_status_summary(..., scope="active")` returns counts PENDING/RUNNING/COMPLETED/FAILED using one GROUP BY. Web GET routes accept scope, keep valid filters/query_base on pagination, and expose a selected `job_id` result with untruncated error. `status=RESOLVED&scope=all` retains historic blocker access.
|
||||
|
||||
- [ ] RED: tests for active defaults excluding revoked/inactive records, `scope="all"` preserving history, `status="RESOLVED"`, `/blockers?field=NCM&scope=active`, `/jobs?status=FAILED&scope=active&job_id=123`, and no product dropdown; run focused tests and see active-scope failures.
|
||||
- [ ] GREEN: apply active predicate after existing joins (for blocker query, require linked active installation only when `scope=active`); keep `page_size=50`, filters and count separate. Add small status GROUP BY and route params. Render one selected job detail from the returned page data or a single scoped `job_id` query; full error only there. Re-run focused tests.
|
||||
|
||||
### Task 4: Human-readable presentation and dashboard/filters markup
|
||||
|
||||
**Files:** `central/app/panel_presentation.py`, `central/templates/dashboard.html`, `central/templates/agents.html`, `central/templates/blockers.html`, `central/templates/jobs.html`, `central/tests/test_control_panel_pages.py`, `central/tests/test_operational_view_models.py`.
|
||||
|
||||
**Interfaces:** `job_status_label`, `job_kind_label`, `blocker_type_label`, `blocker_reason_label`, `priority_label`, `fiscal_status_label` map known enums, falling back to readable text; retain raw value on details. All dashboard health cards are `<a class="metric metric-link" href="/agents?scope=active&health=...">`; blocker field positive links preserve scope/status; zero stays text. Filter chips and clear links display active selections.
|
||||
|
||||
- [ ] RED: TestClient HTML assertions for card→health filter, breakdown NCM→field, failed jobs→FAILED, outdated→version, selected filters/clear actions, human PT-BR labels; confirm old HTML fails.
|
||||
- [ ] GREEN: add presenter mappings to `panel_presentation.py` and include presentation fields in view-model dictionaries; render cards as links, attention groups with count/CTA, compact failed jobs/outdated; edit Agents table to combine identity columns, Blockers table product link + human labels, Jobs compact columns + detail/error. Keep historical routes intact, use `query_base`/`urlencode` to preserve filters and avoid XSS/unescaped params. Re-run HTML assertions.
|
||||
- [ ] Retain the Nova instalação combobox unchanged: TestClient asserts `client_id` hidden, searchable active Gerenciador-only clients, keyboard/mouse JS and server-side validation still present.
|
||||
|
||||
### Task 5: Interaction/CSS and responsive presentation
|
||||
|
||||
**Files:** `central/static/control-panel.css`, `central/static/control-panel.js`, templates from Task 4.
|
||||
|
||||
- [ ] RED: browser preview at 1366/1920/375 exposes horizontal scroll on Agents today and non-clickable cards; capture snapshot/measure `scrollWidth`, focus order and toolbar visibility.
|
||||
- [ ] GREEN: use only `var(--cor-*)` tokens for `.metric-link:hover/:focus-visible`, active-filter chips, grouped attention, semantic badges, compact tables and responsive 375px row layout. Set Agents table sizing to available content area and hide secondary fields below tablet; no 960px minimum for Agents on desktop. Keep Blockers/Jobs list bounded and filters stacked on mobile. Vanilla JS event delegation on `tr[data-href]` for mouse clicks outside existing links; keep primary `<a>` for Tab/Enter and avoid nested controls/intercepting modifier clicks.
|
||||
- [ ] Browser check at 1366x768, 1920x1080, 375px for focus-visible, Tab/Enter (and Space on buttons), no desktop horizontal scroll in Agents, readable names, light/dark styles. Adjust CSS and repeat visual checks until criteria pass.
|
||||
|
||||
### Task 6: Full verification and official deploy
|
||||
|
||||
**Files:** `scripts/deploy_hub.ps1` (execute only; do not edit), test suites.
|
||||
|
||||
- [ ] Run `python -m pytest central/tests -q`, `python -m pytest tests -q`, and `python -m pytest agent/tests -q`; capture totals and failures. Verify no runtime/engine/SSO/auth files changed; `git diff --check` and inspect status/diff.
|
||||
- [ ] Verify dashboard query count and, if SQL plan shows a missing index, first add a small Alembic migration plus migration test, re-run affected and full tests, and deploy with the same official script. No migrations solely for aesthetics.
|
||||
- [ ] Use authenticated browser smoke on Resumo, Agents, Blockers, Jobs and Nova instalação; click Resumo→Online/Total/NCM/failed job/outdated Agent and navigate back. Recheck server-side selected filters and disabled/revoked exclusion at 1920x1080, 1366x768, 375px in light and dark. If production SSO login is unavailable, do equivalent smoke with TestClient/local preview and report production visual check as blocked.
|
||||
- [ ] Run only `powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1` (or `pwsh -File .\scripts\deploy_hub.ps1` if script requires pwsh) as documented in `docs/DEPLOY_HUB.md`. Check `logs/last_deploy_result.json`, release health and public `https://gtifiscal.gtigrupo.com.br/health`. No ad-hoc SSH, no write to Firebird, no client action.
|
||||
- [ ] Report the user-requested field list with observed evidence; `COMMITS=nenhum` if no explicit request to commit, and truthful blockers where browser authentication/production smoke cannot be completed.
|
||||
@@ -0,0 +1,41 @@
|
||||
# GTI Fiscal — painel operacional com drill-down
|
||||
|
||||
**Objetivo.** Refinar as telas atuais Resumo, Agents, Blockers, Jobs e Nova instalação para operação de ~70 clientes, inclusive clientes com 20.000+ produtos. Preservar visual do Gerenciador, stack Jinja/FastAPI/SQLAlchemy e funcionalidades fiscais, Agent, SSO e sincronização existentes. Sem gráficos decorativos, framework frontend novo nem carga de produtos no browser.
|
||||
|
||||
## Semântica operacional e histórico
|
||||
|
||||
Uma expressão SQL compartilhada define `OPERATIONALLY_ACTIVE` para instalação vinculada a Agent: `Tenant.active IS TRUE`, `Agent.enabled IS TRUE` e ausência de enrollment vinculado em estado `REVOKED`, `EXPIRED` ou `FAILED` (estados finais/inativos). Agent legado sem enrollment não é revogado por inferência; clientes `LOCAL` não são classificados por nome. `Installation.processing_enabled=false` pausa processamento, **não** equivale a instalação revogada: ainda pode comunicar heartbeat e indicar indisponibilidade. Nunca excluir registros por nome `E2E` ou por versão antiga. O registro histórico permanece intacto.
|
||||
|
||||
Dashboard usa exclusivamente esta expressão para contagem de saúde, soma de snapshots/cobertura, blockers operacionais, jobs falhos operacionais, versão desatualizada e atenção. Blockers ativos requerem instalação vinculada operacionalmente ativa e `resolved_at IS NULL`; blockers legados sem instalação e blockers resolvidos permanecem nas listagens históricas. A página Agents usa `scope=active` por padrão e `scope=all` para inventário/histórico (com indicação de histórico e badges); Blockers/Jobs usam o mesmo `scope` para a lista e seus KPIs, preservando filtros adicionais. Um card do Resumo sempre leva à visão `scope=active` e ao filtro correspondente. O filtro `scope=all` inclui registros inativos/revogados, e `status=RESOLVED` em Blockers permite acessar problemas resolvidos. Totais do dashboard e contagens da listagem ativa são reconciliados pela mesma expressão SQL.
|
||||
|
||||
## Resumo: decisão em um clique
|
||||
|
||||
Seis cards `<a>` completos para Total, Online, Degraded, Offline, Failed, Unknown; destino `/agents?scope=active` com `health=<ENUM>` quando aplicável. Labels de apresentação PT-BR: Servidores, Online, Atenção, Offline, Falhou, Não informado. A identidade técnica do filtro permanece na URL e na query SQL. Cobertura: total/completos levam a Agents com ordenação fiscal (visão disponível), bloqueados levam a `/blockers?scope=active&status=OPEN`, falhas técnicas a `/agents?scope=active&health=FAILED`, e não classificados a Agents ordenados por pior cobertura. Sem prometer filtros de produto que o sistema não possui. O breakdown CFOP/ST/ICMS/CSOSN/NCM/OTHER liga valores positivos a `/blockers?scope=active&status=OPEN&field=<ENUM>`; zeros ficam visualmente neutros.
|
||||
|
||||
“Atenção necessária” usa até 10 **grupos**, não 10 linhas cruas: primeiro instalações com falha técnica, depois saúde Failed/Degraded/Offline, grupos de blockers fiscais por instalação/campo/tipo/motivo, versões desatualizadas e outros estados realmente acionáveis. Dentro do grupo fiscal: número de blockers abertos e produtos distintos; agrupar **em SQL antes do LIMIT**, com ordenação por gravidade e recência. Um grupo oferece link de lista filtrada de blockers ou detalhe de Agent/job. Instalações revogadas, agentes desabilitados, clientes inativos, itens resolvidos ou sem destino operacional não aparecem. Link “Ver todos” abre lista operacional correspondente. Evitar duplicar a mesma instalação como alerta de versão quando um alerta técnico de prioridade maior já a representa; histórico continua nas listas.
|
||||
|
||||
Jobs falhos: contagem e lista curta da operação ativa com Cliente, Agent/hostname, tipo, erro truncado, momento e link `Ver job` para `/jobs?scope=active&status=FAILED&job_id=<id>`. A página Jobs exibe erro integral quando `job_id` é informado. Link “Ver jobs” abre `status=FAILED`. Agents desatualizados: lista compacta em largura suficiente (sem sidebar estreita), cliente/hostname, versão atual/atualização e detalhe do Agent; poucos itens mais “Ver todos” com `version=outdated` aplicado.
|
||||
|
||||
## Agents / Servidores
|
||||
|
||||
Uma tabela densa, legível em 1366×768 sem scroll horizontal: cliente com instalação/hostname na mesma célula, saúde, fiscal (quantidades resumidas), cobertura, produtos, versão e último contato. Texto secundário só quando existir; badges mostram apenas o valor humano, sem repetir “Saúde técnica:” ou “Status fiscal:”. Busca, saúde (todos os cinco estados), ordenação e scope via GET; chip de filtro ativo e “Limpar filtros”; paginação mantém todos os parâmetros. Link primário visível no cliente/hostname é nativamente acessível por Tab/Enter; clique com mouse na restante da linha abre o mesmo detalhe via delegação pequena em JS, sem eliminar a semântica da tabela. Em telas pequenas, linhas podem assumir apresentação compacta com rótulos por célula, mantendo filtros e ações expostos.
|
||||
|
||||
## Blockers
|
||||
|
||||
KPIs coerentes com `scope`, `status` e demais filtros (abertos, produtos/clientes afetados, resultado filtrado), com links apenas quando a métrica tem destino fiel. Breakdown filtrado liga campo positivo ao mesmo conjunto de filtros, trocando só `field`; filtro selecionado fica visível. Filtros existentes permanecem server-side (cliente, instalação, campo, tipo, status, idade, busca e identificadores existentes), dispostos em uma ou duas linhas no desktop e empilhados no mobile, com “Limpar filtros” retornando à visão ativa padrão.
|
||||
|
||||
Tabela destaca código e descrição do produto como link para `/blockers/<id>`; exibe cliente/servidor, campo, tipo humano, prioridade humana, problema humano, último registro e CTA. Tradutor somente de apresentação mapeia enums conhecidos (`BLOCKED_MISSING_EVIDENCE` → `Evidência fiscal ausente`, `INVALID_NCM` → `NCM inválido`, prioridades `CRITICAL/HIGH/MEDIUM/LOW` → `Crítica/Alta/Média/Baixa`) e cai em rótulo legível para desconhecidos; o detalhe mantém o código técnico original. Paginação limitada (50), consulta com COUNT separado, sem dropdown de produtos nem N+1.
|
||||
|
||||
## Jobs
|
||||
|
||||
Atalhos compactos para `PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, todos filtrando `scope=active` e mostrando a seleção. Status PT-BR: Pendente, Executando, Concluído, Falhou; tipos conhecidos (`SYNC_INCREMENTAL`, `INITIAL_SYNC`, `REPROCESS_ALL`, `REPROCESS_PRODUCTS`) ganham labels humanos. A lista agrupa cliente e servidor, depois tipo, status, criado/finalizado, duração e erro curto. Erro integral e enum técnico aparecem na vista filtrada `job_id` da própria página, sem criar uma tela artificial. Seleção de scope histórico e “Limpar filtros” mantêm os demais parâmetros úteis. Não acionar jobs, somente leitura do banco.
|
||||
|
||||
## Estilo, acessibilidade e escala
|
||||
|
||||
Usar somente `central/static/control-panel.css` sobre os tokens imutáveis `gti-theme.css`. Estados semânticos: sucesso verde, atenção amarelo, falha vermelha, histórico/desconhecido cinza, bloqueio fiscal laranja. Foco visível, navegação Tab/Enter e Space nos controles apropriados, links `<a>` em cards; nenhum `div` simulando botão. Mobile 375 px preserva busca e ações, desktop 1366×768 e 1920×1080 aproveitam largura sem blocos de vazio. Testar light/dark por screenshot, não só inspecionando tokens. Combobox de clientes ativos da Nova instalação permanece com busca nome/CNPJ, mouse/teclado, `client_id` hidden e validação server-side; só retoque estético se necessário.
|
||||
|
||||
Queries devem usar agregados e grupos SQL, bounded LIMIT, COUNT e paginação server-side; não carregar listas de produtos/snapshots inteiros nem emitir uma consulta por item. Medir número de queries do dashboard e inspecionar planos quando filtros/campos precisarem de índice. Se necessário, migration de índices pequena e reversível; nunca mudar engine fiscal ou dados históricos. Regressões confirmam exclusão de inativos/revogados, coerência card→querystring→resultado, agrupamento, labels, paginação e query count/ausência de N+1.
|
||||
|
||||
## Validação e deploy
|
||||
|
||||
Rodar suites existentes e regressões focadas. Browser smoke em Resumo, Agents, Blockers, Jobs, Nova instalação; navegar Resumo→Online/Total/NCM/Jobs falhos/Agent desatualizado e voltar; verificar light/dark nos viewports 1920×1080, 1366×768, 375 px e Tab/Enter/Space. Nova instalação não deve criar registros no smoke. Usar **exclusivamente** `scripts/deploy_hub.ps1` para deploy/backup/migration/rollback/health, sem SSH improvisado nem scripts paralelos. Verificar health público e métricas operacionais pós-deploy. Se autenticação visual do Gerenciador não estiver disponível, reportar explicitamente o bloqueio em vez de alegar screenshot de produção.
|
||||
Reference in New Issue
Block a user