From 0eeb7d159c456ad85538d5b5e94c08f1a91f51f7 Mon Sep 17 00:00:00 2001 From: gti Date: Tue, 22 Sep 2026 20:34:03 -0300 Subject: [PATCH] fix: close operational panel page review findings --- .../task-4-report.md | 44 +++++++ central/app/control_plane.py | 49 +++++++- central/app/events.py | 4 +- central/app/web.py | 62 +++++++--- central/templates/agent_detail.html | 8 +- central/templates/blockers.html | 6 +- central/templates/dashboard.html | 2 +- central/templates/reprocess_confirmation.html | 15 +++ central/tests/test_control_panel_pages.py | 111 +++++++++++++++++- 9 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 .superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-4-report.md create mode 100644 central/templates/reprocess_confirmation.html diff --git a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-4-report.md b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-4-report.md new file mode 100644 index 0000000..0078d83 --- /dev/null +++ b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-4-report.md @@ -0,0 +1,44 @@ +# Task 4 Report: Server-Rendered Operational Pages + +STATUS: COMPLETE + +## Implemented + +- Added authenticated page tests covering dashboard, agents, jobs, blockers, agent detail, blocker detail, filters, pagination state, human dates, and safe-form constraints. +- Added server-side route filters for agents, blockers, jobs, and agent events while preserving bounded view-model calls. +- Reworked the Jinja pages with Portuguese operational labels, separate technical/fiscal statuses, empty states, detail links, accessible controls, semantic table captions/headers, and secondary technical IDs. +- Added dashboard attention, failed-job, outdated-agent, and blocker summary sections. +- Preserved the Task 3 blocker detail and CSRF-protected resolution form, with evidence and related operational records. +- Replaced raw primary timestamp rendering with humanized presentation values. +- Replaced the old confirmation JavaScript with harmless progressive enhancement; no `window.confirm` or `data-confirm` remains in the panel pages. +- Added responsive table overflow, mobile breakpoints, visible focus states, and text-bearing status badges. + +## Verification + +- RED: `python -m pytest central/tests/test_control_panel_pages.py -q` failed 5 tests against the pre-change templates/routes. +- Focused: `python -m pytest central/tests/test_control_panel_pages.py -q` passed 5 tests. +- Central suite: `python -m pytest central/tests -q` passed 271 tests with 1 existing openpyxl warning. +- `git diff --check` passed. +- No credentials, tokens, cookies, or secrets were added. + +## Concerns + +- The blocker page's affected-client count is derived from the bounded filtered result page; the canonical global blocker/product metrics continue to come from `dashboard_summary`. +- Browser viewport screenshots were not run in this offline task; responsive behavior is covered by CSS breakpoints and horizontal table scrolling. + +Commit message: `feat: refine operational control panel pages` + +## Task 4 review fixes + +- Added a dashboard list of at most 10 recent failed jobs with client, installation display name, hostname, job ID/kind, short error, created/finished display and age, and a job-ID-filtered link. The failed-job total and empty state remain visible. +- Replaced page-local/global blocker metric mixing with SQL-backed aggregates over the complete relation selected by the current filters. Counts use distinct `(tenant_id, product_code)` identity and distinct tenant IDs; OPEN, RESOLVED, and ALL behavior is covered. Breakdown now follows the filtered relation. +- Agent detail now loads its installation, agent, tenant, and snapshot by agent ID, without scanning the first 100 installations. Agent blocker breakdown and its filtered link include only blockers whose installation belongs to that agent; tenant-only legacy records are excluded. +- Added event since/until filters and page-size+1 lookahead, preserving filters in pagination links and omitting Next on the final page. Job references link to jobs filtered by job ID; product references link to blockers filtered by agent/product. +- Reprocess-all now opens an explicit server-rendered confirmation page with a required confirmation checkbox, CSRF token, and cancel link. Missing confirmation is rejected; valid confirmation retains the guarded enqueue path. +- Added route regressions for these cases, including 55+ blockers, repeated product code across tenants, resolved/all status, agent ID beyond 100, exact blocker attribution, date filtering/pagination, links, and confirmation/CSRF behavior. +- RED evidence: the new dashboard-row and date-filter cases failed before implementation because those views were absent; after correcting test setup, the new assertions detected the unimplemented behaviors. Focused regressions are green after implementation. +- Verification: `python -m pytest central/tests/test_control_panel_pages.py -q` — 9 passed; `python -m pytest central/tests -q` — 275 passed, 1 existing openpyxl warning; `git diff --check` — passed. +- Route smoke coverage is provided by authenticated TestClient requests. CSS breakpoint review confirmed the existing 1100px, 768px, and 520px responsive rules and table overflow remain intact. +- The earlier concern that blocker metrics were page-local is resolved by the complete filtered SQL aggregates above. No writes to Firebird databases were performed. + +Commit message: `fix: close operational panel page review findings` diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 9232c80..8231187 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -227,7 +227,7 @@ def _parse_age(value): return None -def _blockers_query(session, *, tenant_id=None, agent_id=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, status="OPEN", client=None, installation=None, search=None, age=None): 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) status = (status or "OPEN").upper() if status == "OPEN": @@ -238,6 +238,8 @@ def _blockers_query(session, *, tenant_id=None, agent_id=None, blocker_type=None query = query.where(BlockedProduct.tenant_id == tenant_id) if agent_id is not None: query = query.where(Agent.id == agent_id) + if product_code is not None: + query = query.where(BlockedProduct.product_code == product_code) if blocker_type: query = query.where(BlockedProduct.blocker_type == blocker_type) if field: @@ -255,11 +257,30 @@ def _blockers_query(session, *, tenant_id=None, agent_id=None, blocker_type=None return query -def list_blockers(session, *, tenant_id=None, agent_id=None, page=1, page_size=50, blocker_type=None, field=None, resolved=False, client=None, installation=None, status="OPEN", age=None, search=None): +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) + 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(query.with_only_columns(func.coalesce(BlockedProduct.field, "OTHER"), func.count(BlockedProduct.id)).group_by(func.coalesce(BlockedProduct.field, "OTHER")).order_by(None)).all() + breakdown = {field: 0 for field in BLOCKER_FIELDS} + for field_name, count in rows: + key = str(field_name or "").upper() + breakdown[key if key in breakdown else "OTHER"] += int(count) + pair_query = query.with_only_columns(BlockedProduct.tenant_id, BlockedProduct.product_code).distinct().subquery() + client_query = query.with_only_columns(BlockedProduct.tenant_id).distinct().subquery() + return { + "open_blockers": int(open_count or 0), + "affected_products": int(session.scalar(select(func.count()).select_from(pair_query)) or 0), + "affected_clients": int(session.scalar(select(func.count()).select_from(client_query)) or 0), + "blocker_breakdown": breakdown, + } + + +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): 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, 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, status=status, client=client, installation=installation, search=search, age=age) 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] @@ -290,11 +311,13 @@ def _job_item(row, agent, installation, tenant, *, now=None): } -def list_jobs(session, *, agent_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): 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 agent_id is not None: query = query.where(Job.agent_id == agent_id) + if job_id is not None: + query = query.where(Job.id == job_id) if status: query = query.where(Job.status == status) if kind: @@ -380,6 +403,19 @@ def dashboard_summary(session, now=None): 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 + 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) + ).all() + recent_failed_jobs = [] + for job, agent, installation, tenant in failed_job_rows: + item = _job_item(job, agent, installation, tenant, now=now) + item["error"] = (item["error"] or "")[:240] + item["created_age"] = format_age(job.created_at, now) + item["finished_age"] = format_age(job.finished_at, now) + item["link"] = f"/jobs?job_id={job.id}" + recent_failed_jobs.append(item) return { "agents_total": sum(counts.values()), **{key.lower(): value for key, value in counts.items()}, @@ -393,6 +429,7 @@ def dashboard_summary(session, now=None): "open_blocker_breakdown": open_blocker_breakdown, "attention_items": [entry[0] for entry in attention[:10]], "failed_jobs": failed_jobs, + "recent_failed_jobs": recent_failed_jobs, } @@ -432,6 +469,6 @@ def blocker_detail(session, blocker_id): } -def list_panel_events(session, installation_db_id, **filters): - rows = list_events(session, installation_db_id, **filters) +def list_panel_events(session, installation_db_id, *, include_more=False, **filters): + rows = list_events(session, installation_db_id, fetch_extra=include_more, **filters) return [{"id": row.id, "severity": row.severity, "event_type": row.event_type, "message": row.message, "context": row.structured_context, "job_id": row.job_id, "product_code": row.product_code, "occurred_at": _iso(row.occurred_at)} for row in rows] diff --git a/central/app/events.py b/central/app/events.py index 96bffe6..068c0b7 100644 --- a/central/app/events.py +++ b/central/app/events.py @@ -61,7 +61,7 @@ def purge_expired_events(session, now: datetime | None = None, retention_days: i return result.rowcount or 0 -def list_events(session, installation_db_id: int, *, page: int = 1, page_size: int = 50, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: datetime | None = None, until: datetime | None = None): +def list_events(session, installation_db_id: int, *, page: int = 1, page_size: int = 50, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: datetime | None = None, until: datetime | None = None, fetch_extra: bool = False): page = max(1, page); page_size = min(100, max(1, page_size)) query = select(AgentEvent).where(AgentEvent.installation_id == installation_db_id) for column, value in ((AgentEvent.severity, severity), (AgentEvent.event_type, event_type), (AgentEvent.job_id, job_id), (AgentEvent.product_code, product_code)): @@ -70,5 +70,5 @@ def list_events(session, installation_db_id: int, *, page: int = 1, page_size: i if since: query = query.where(AgentEvent.occurred_at >= since) if until: query = query.where(AgentEvent.occurred_at <= until) query = query.order_by(AgentEvent.occurred_at.desc(), AgentEvent.id.desc()) - rows = session.scalars(query.offset((page - 1) * page_size).limit(page_size)).all() + rows = session.scalars(query.offset((page - 1) * page_size).limit(page_size + int(fetch_extra))).all() return rows diff --git a/central/app/web.py b/central/app/web.py index 60ce3de..2fbbc31 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -1,6 +1,6 @@ """Server-rendered operator pages for the GTI Fiscal control panel.""" -from datetime import datetime +from datetime import datetime, timezone from urllib.parse import parse_qs, urlencode from fastapi import APIRouter, Request @@ -11,10 +11,10 @@ from sqlalchemy import select from .config import settings from .blocker_resolution import _company_key, resolve_blocker_with_context from .context_keys import ALLOWED_KEYS, ENUMS -from .control_plane import blocker_detail, dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events +from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, 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, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant +from .models import AdminUser, Agent, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant from .jobs import create_job from .audit import record_audit @@ -33,6 +33,17 @@ def router(session_factory): def query_base(**values): return urlencode({key: value for key, value in values.items() if value not in (None, "")}) + def date_filter(value, *, end_of_day=False): + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.replace(hour=23, minute=59, second=59, microsecond=999999) if end_of_day and len(value) == 10 else parsed + except ValueError: + return None + @routes.get("/login", response_class=HTMLResponse) def login_page(request: Request): return render(request, "login.html", {"error": None}) @@ -88,18 +99,32 @@ def router(session_factory): finally: session.close() @routes.get("/agents/{agent_id}", response_class=HTMLResponse) - def agent_detail(request: Request, agent_id: int, tab: str = "overview", event_page: int = 1, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None): + def agent_detail(request: Request, agent_id: int, tab: str = "overview", event_page: int = 1, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: str | None = None, until: str | None = None): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: - result = list_installations(session, page=1, page_size=100) - detail = next((item for item in result["items"] if item["agent_id"] == agent_id), None) - if detail is None: return HTMLResponse("Agent not found", status_code=404) - installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) - events = list_panel_events(session, installation.id, page=event_page, page_size=25, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code) + row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first() + if row is None: return HTMLResponse("Agent not found", status_code=404) + installation, agent, tenant, snapshot = row + detail = installation_view(installation, agent, tenant, snapshot) + event_page = max(1, event_page) + events = list_panel_events(session, installation.id, page=event_page, page_size=25, include_more=True, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=date_filter(since), until=date_filter(until, end_of_day=True)) + has_more = len(events) > 25 + events = events[:25] for event in events: event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None) - return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25), "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")}) + 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", "")}) + finally: session.close() + + @routes.get("/agents/{agent_id}/reprocess-confirmation", response_class=HTMLResponse) + def reprocess_confirmation(request: Request, agent_id: int): + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + if not session.scalar(select(Agent.id).where(Agent.id == agent_id)): + return HTMLResponse("Agent not found", status_code=404) + return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data}) finally: session.close() @routes.post("/agents/{agent_id}/reprocess") @@ -109,7 +134,7 @@ def router(session_factory): try: values = await form_values(request); web_session, admin = auth_data require_csrf(web_session, values.get("csrf_token")) - if values.get("confirm") != "yes": return HTMLResponse("confirmation required", status_code=400) + if values.get("confirm") != "yes": return render(request, "reprocess_confirmation.html", {"agent_id": agent_id, "auth": auth_data, "error": "Confirme explicitamente o reprocessamento."}, 400) agent = session.get(Agent, agent_id) installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) @@ -121,15 +146,14 @@ 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): + 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): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: - result = list_blockers(session, page=page, page_size=50, client=client, installation=installation, field=field, blocker_type=blocker_type, status=status, age=age, search=search) - summary = dashboard_summary(session) - affected_clients = len({item["client"] for item in result["items"] if item.get("client")}) - affected_products = len({item["product_code"] for item in result["items"] if item.get("product_code")}) - return render(request, "blockers.html", {"result": result, "summary": summary, "affected_clients": affected_clients, "affected_products": affected_products, "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(client=client, installation=installation, field=field, blocker_type=blocker_type, status=status, age=age, search=search)}) + 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} + 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)}) finally: session.close() @routes.get("/blockers/{blocker_id}", response_class=HTMLResponse) @@ -168,10 +192,10 @@ 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): + def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | None = None, job_id: int | None = None): 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), "auth": auth_data, "status_filter": status or "", "search": search or "", "kind_filter": kind or "", "query_base": query_base(status=status, search=search, kind=kind)}) + 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)}) finally: session.close() return routes diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index c3c8927..aa71824 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -4,8 +4,8 @@

{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO

{{ detail.display_name or detail.hostname or detail.installation_id }}

{{ detail.hostname or "hostname indisponível" }}

Saúde técnica: {{ detail.technical_health_label }}Status fiscal: {{ detail.fiscal_status_label }}
Produtos{{ detail.total_products }}
Completos{{ detail.fiscally_complete }}
Bloqueados com segurança{{ detail.safe_blocked }}
Cobertura fiscal{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%{{ detail.fiscally_complete }} / {{ detail.total_products }}
-

Contexto da empresa

Cliente
{{ detail.client or "-" }}
Nome de exibição
{{ detail.display_name or "-" }}
Hostname
{{ detail.hostname or "-" }}
Empresa
{{ detail.company_name or detail.company_code or "-" }}
UF
{{ detail.company_uf or "-" }}
CRT
{{ detail.company_crt or "-" }}
Versão do agent
{{ detail.agent_version or "Desconhecida" }} · {{ detail.version_drift|title }}
Último heartbeat
{{ detail.last_seen_display }} · {{ detail.last_seen_age }}
Firebird
{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}
IDs técnicos
Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}
-

Eventos recentes

Ver eventos
{% for event in events[:8] %}
{{ event.occurred_display }}{{ event.event_type }}{{ event.message }}{% if event.job_id %} · Job {{ event.job_id }}{% endif %}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}Severidade: {{ event.severity }}
{% else %}

Nenhum evento.

{% endfor %}
-

Eventos operacionais

Filtros aplicados no servidor.

-

Blockers abertos

Ver todos
{% for item in blockers["items"][:8] %}{{ item.product_code }} · {{ item.field }}{{ item.reason }} · {{ item.age }}{{ item.blocker_type }}{% else %}

Nenhum blocker aberto.

{% endfor %}
+

Contexto da empresa

Reprocessar tudo
Cliente
{{ detail.client or "-" }}
Nome de exibição
{{ detail.display_name or "-" }}
Hostname
{{ detail.hostname or "-" }}
Empresa
{{ detail.company_name or detail.company_code or "-" }}
UF
{{ detail.company_uf or "-" }}
CRT
{{ detail.company_crt or "-" }}
Versão do agent
{{ detail.agent_version or "Desconhecida" }} · {{ detail.version_drift|title }}
Último heartbeat
{{ detail.last_seen_display }} · {{ detail.last_seen_age }}
Firebird
{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}
IDs técnicos
Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}
+

Eventos recentes

Ver eventos
{% for event in events[:8] %}
{{ event.occurred_display }}{{ event.event_type }}{{ event.message }}{% if event.job_id %} · Job {{ event.job_id }}{% endif %}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}Severidade: {{ event.severity }}
{% else %}

Nenhum evento.

{% endfor %}
+

Eventos operacionais

Filtros aplicados no servidor.

+

Blockers abertos

Ver todos
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ blocker_breakdown[key] }}
{% endfor %}
{% for item in blockers["items"][:8] %}{{ item.product_code }} · {{ item.field }}{{ item.reason }} · {{ item.age }}{{ item.blocker_type }}{% else %}

Nenhum blocker aberto.

{% endfor %}
{% endblock %} diff --git a/central/templates/blockers.html b/central/templates/blockers.html index b486d64..a76590a 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -2,9 +2,9 @@ {% block title %}Blockers | GTI Fiscal{% endblock %} {% block content %}

FILA FISCAL

Blockers globais

Problemas fiscais separados de falhas técnicas.

-
Blockers abertos{{ summary.open_blockers }}
Produtos afetados{{ summary.safe_blocked_products }}
Clientes afetados{{ affected_clients }}
Resultado filtrado{{ result.total }}
-
-

Distribuição dos blockers abertos

CFOP {{ summary.open_blocker_breakdown.CFOP }} · ST {{ summary.open_blocker_breakdown.ST }} · ICMS {{ summary.open_blocker_breakdown.ICMS }} · CSOSN {{ summary.open_blocker_breakdown.CSOSN }} · NCM {{ summary.open_blocker_breakdown.NCM }} · OTHER {{ summary.open_blocker_breakdown.OTHER }}
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ summary.open_blocker_breakdown[key] }}
{% endfor %}
+
Blockers abertos{{ summary.open_blockers }}
Produtos afetados{{ summary.affected_products }}
Clientes afetados{{ summary.affected_clients }}
Resultado filtrado{{ result.total }}
+
{% if request.query_params.get('agent_id') %}{% endif %}{% if request.query_params.get('product_code') %}{% endif %}
+

Distribuição dos blockers filtrados

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

FILA DE AÇÃO

ATENÇÃO NECESSÁRIA

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

Nenhum item requer atenção imediata.

{% endfor %}
-

Jobs com falha

Ver jobs
{% if summary.failed_jobs %}

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

{% else %}

Nenhum job com falha.

{% endif %}
+

Jobs com falha

Ver jobs
{% if summary.failed_jobs %}

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

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

Nenhum job com falha.

{% endif %}
{% endblock %} diff --git a/central/templates/reprocess_confirmation.html b/central/templates/reprocess_confirmation.html new file mode 100644 index 0000000..0632bad --- /dev/null +++ b/central/templates/reprocess_confirmation.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Confirmar reprocessamento | GTI Fiscal{% endblock %} +{% block content %} +
+

AÇÃO OPERACIONAL

+

Confirmar reprocessamento

+

O reprocessamento completo será enfileirado para esta instalação.

+ {% if error %}{% endif %} +
+ + +

Cancelar

+
+
+{% endblock %} diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index f4e0a05..cbc6f0c 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -7,7 +7,7 @@ from sqlalchemy import select from central.app.blocked_products import upsert_blocker from central.app.events import record_event from central.app.main import create_app -from central.app.models import Agent, AgentSnapshot, Installation, Job +from central.app.models import Agent, AgentEvent, AgentSnapshot, Installation, Job, Tenant from central.tests.test_control_plane import seed, setup_db @@ -64,6 +64,7 @@ def _panel_client(): session.commit() client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)) + client._session_factory = session_factory login = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False) assert login.status_code == 303 return client, agent_id, blocker.id @@ -86,6 +87,56 @@ def test_dashboard_renders_operational_queue_labels_and_human_dates(): assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", response.text) +def test_dashboard_lists_bounded_failed_job_rows_with_identity_error_age_and_empty_state(): + client, agent_id, _ = _panel_client() + factory = client._session_factory + with factory() as session: + for index in range(12): + session.add(Job(agent_id=agent_id, kind="REPROCESS_PRODUCTS", status="FAILED", error=f"erro curto {index}", created_at=datetime(2026, 9, 22, 18, 0, tzinfo=timezone.utc))) + session.commit() + 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 response.text.count("erro curto") == 10 + with factory() as session: + session.query(Job).filter(Job.status == "FAILED").delete() + session.commit() + assert "Nenhum job com falha." in client.get("/").text + + +def test_blocker_summary_uses_complete_filtered_relation_and_tenant_product_identity(): + client, agent_id, _ = _panel_client() + factory = client._session_factory + with factory() as session: + original = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + other_tenant = Tenant(name="Outra empresa") + session.add(other_tenant); session.flush() + other_agent = Agent(tenant_id=other_tenant.id, name="agent-2", token_hash="y" * 64) + session.add(other_agent); session.flush() + other_installation = Installation(agent_id=other_agent.id, tenant_id=other_tenant.id, installation_id="22222222-2222-4222-8222-222222222222", display_name="Outra ERP", hostname="srv-02") + session.add(other_installation); session.flush() + for index in range(55): + upsert_blocker(session, original.tenant_id, {"company": "Acme", "product_code": f"P-{index:03}", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": f"many-{index}"}, installation_id=original.id) + upsert_blocker(session, other_tenant.id, {"company": "Outra", "product_code": "P-000", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "NCM", "reason_code": "MISSING", "input_fingerprint": "other-tenant"}, installation_id=other_installation.id) + resolved = upsert_blocker(session, original.tenant_id, {"company": "Acme", "product_code": "P-RES", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "ST", "reason_code": "MISSING", "input_fingerprint": "resolved"}, installation_id=original.id) + resolved.resolved_at = datetime.now(timezone.utc) + session.commit() + open_page = client.get("/blockers?status=OPEN&field=CFOP") + assert open_page.status_code == 200 + assert 'Blockers abertos56' in open_page.text + assert 'Produtos afetados55' in open_page.text + assert 'Clientes afetados1' in open_page.text + resolved_page = client.get("/blockers?status=RESOLVED") + assert 'Blockers abertos0' in resolved_page.text + assert 'Produtos afetados1' in resolved_page.text + all_page = client.get("/blockers?status=ALL") + assert 'Blockers abertos57' in all_page.text + assert 'Produtos afetados57' in all_page.text + assert "Produtos afetados" in open_page.text and "Clientes afetados" in open_page.text + + def test_agents_render_separate_health_and_fiscal_status_with_server_sort(): client, _, _ = _panel_client() @@ -135,6 +186,10 @@ def test_blockers_render_visible_filters_summary_priority_and_detail_link(): def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows(): client, agent_id, blocker_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": "LEGACY", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "ST", "reason_code": "LEGACY", "input_fingerprint": "tenant-only"}) + session.commit() agent_response = client.get(f"/agents/{agent_id}?tab=events&severity=WARNING&event_type=PRODUCT_BLOCKED&page=2") blocker_response = client.get(f"/blockers/{blocker_id}") @@ -143,9 +198,14 @@ def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows(): for label in ("Cliente", "Nome de exibição", "Hostname", "Saúde técnica", "Status fiscal", "Último heartbeat", "Versão", "Empresa", "UF", "CRT", "Eventos recentes", "Severidade", "Tipo de evento"): assert label in agent_response.text assert "Contexto fiscal ausente" in agent_response.text + assert f"/blockers?agent_id={agent_id}" in agent_response.text + assert ">CFOP1" in agent_response.text + assert ">ST0" in agent_response.text + agent_blockers = client.get(f"/blockers?agent_id={agent_id}") + assert "Produto de teste" in agent_blockers.text and "LEGACY" not in agent_blockers.text assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", agent_response.text) - assert "severity=WARNING" in agent_response.text - assert "event_type=PRODUCT_BLOCKED" in agent_response.text + assert 'value="WARNING" selected' in agent_response.text + assert 'value="PRODUCT_BLOCKED"' in agent_response.text assert blocker_response.status_code == 200 for label in ("Detalhes do blocker", "Evidência", "Contexto atual", "Salvar contexto e reprocessar", "Chave de contexto", "Valor", "Escopo", "Nota"): @@ -153,3 +213,48 @@ def test_agent_and_blocker_details_keep_safe_forms_and_human_event_rows(): assert 'name="context"' not in blocker_response.text assert "data-confirm" not in blocker_response.text assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", blocker_response.text) + + +def test_agent_detail_resolves_agent_after_first_hundred_installations(): + client, _, _ = _panel_client() + factory = client._session_factory + with factory() as session: + tenant = session.scalar(select(Tenant).where(Tenant.name == "Acme")) + target_id = 0 + for index in range(105): + agent = Agent(tenant_id=tenant.id, name=f"late-{index}", token_hash=f"{index:064d}") + session.add(agent); session.flush() + session.add(Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=f"{index + 10:08d}-1111-4111-8111-111111111111", display_name=f"Late ERP {index}", hostname=f"late-{index}")) + target_id = agent.id + session.commit() + response = client.get(f"/agents/{target_id}") + assert response.status_code == 200 + assert "Late ERP 104" in response.text + + +def test_agent_event_filters_lookahead_and_safe_event_reference_links_and_confirmation(): + client, agent_id, _ = _panel_client() + factory = client._session_factory + with factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + job = session.scalar(select(Job).where(Job.agent_id == agent_id)) + for index in range(27): + session.add(AgentEvent(tenant_id=installation.tenant_id, installation_id=installation.id, agent_id=agent_id, severity="INFO", event_type="HEARTBEAT", message=f"evento {index}", structured_context={}, job_id=job.id, product_code="P-001", occurred_at=datetime(2026, 9, 21 + index % 2, tzinfo=timezone.utc))) + session.add(AgentEvent(tenant_id=installation.tenant_id, installation_id=installation.id, agent_id=agent_id, severity="INFO", event_type="HEARTBEAT", message="evento anterior ao filtro", structured_context={}, occurred_at=datetime(2026, 9, 20, tzinfo=timezone.utc))) + session.commit() + first = client.get(f"/agents/{agent_id}?tab=events&since=2026-09-21&until=2026-09-22") + assert first.status_code == 200 + assert 'name="since"' in first.text and 'name="until"' in first.text + assert "evento anterior ao filtro" not in first.text + assert "Próxima" in first.text and "since=2026-09-21" in first.text and "until=2026-09-22" in first.text + last = client.get(f"/agents/{agent_id}?tab=events&event_page=2&since=2026-09-21&until=2026-09-22") + assert "Anterior" in last.text and "Próxima" not in last.text + assert "/jobs?job_id=" in first.text and "/blockers?agent_id=" in first.text + csrf = client.cookies.get("gti_csrf") + blocked = client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": csrf}) + assert blocked.status_code == 400 and "confirmar" in blocked.text.lower() and 'name="confirm"' in blocked.text + confirmation = client.get(f"/agents/{agent_id}/reprocess-confirmation") + assert confirmation.status_code == 200 and 'name="confirm"' in confirmation.text and 'required' in confirmation.text + assert f'href="/agents/{agent_id}"' in confirmation.text + assert client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": "wrong", "confirm": "yes"}).status_code == 403 + assert client.post(f"/agents/{agent_id}/reprocess", data={"csrf_token": csrf, "confirm": "yes"}, follow_redirects=False).status_code == 303