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.hostname or "hostname indisponível" }}
Nenhum evento.
{% endfor %}Filtros aplicados no servidor.
Nenhum blocker aberto.
{% endfor %}Nenhum evento.
{% endfor %}Filtros aplicados no servidor.
Nenhum blocker aberto.
{% endfor %}FILA FISCAL
Problemas fiscais separados de falhas técnicas.
| Cliente | Instalação | Produto | Campo | Tipo de blocker | Prioridade | Problema | Última ocorrência | Açã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 }} tentativas | Ver detalhe |
| Nenhum blocker corresponde aos filtros. | ||||||||
FILA DE AÇÃO
Nenhum item requer atenção imediata.
{% endfor %}{{ summary.failed_jobs }} jobs aguardam investigação.
{% else %}Nenhum job com falha.
{% endif %}{{ summary.failed_jobs }} jobs aguardam investigação.
| Cliente | Instalação | Job ID / Tipo | Erro | Criado | Finalizado |
|---|---|---|---|---|---|
| {{ 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 }} |
Nenhum job com falha.
{% endif %}Nenhum agent desatualizado.
{% endfor %}AÇÃO OPERACIONAL
+O reprocessamento completo será enfileirado para esta instalação.
+ {% if error %}{{ error }}
{% endif %} + +