fix: close operational panel page review findings
This commit is contained in:
@@ -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`
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+43
-19
@@ -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
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<div class="detail-head"><div><p class="eyebrow">{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO</p><h1>{{ detail.display_name or detail.hostname or detail.installation_id }}</h1><p class="muted">{{ detail.hostname or "hostname indisponível" }}</p></div><div class="badge-stack"><span class="badge {{ detail.technical_health|lower }} large">Saúde técnica: {{ detail.technical_health_label }}</span><span class="badge fiscal-{{ detail.fiscal_status|lower }} large">Status fiscal: {{ detail.fiscal_status_label }}</span></div></div>
|
||||
<section class="cards compact"><article class="metric"><span>Produtos</span><strong>{{ detail.total_products }}</strong></article><article class="metric"><span>Completos</span><strong>{{ detail.fiscally_complete }}</strong></article><article class="metric"><span>Bloqueados com segurança</span><strong>{{ detail.safe_blocked }}</strong></article><article class="metric"><span>Cobertura fiscal</span><strong>{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ detail.fiscally_complete }} / {{ detail.total_products }}</small></article></section>
|
||||
<nav class="tabs" aria-label="Seções da instalação">{% for name,label in [("overview","Resumo"),("fiscal","Fiscal"),("blockers","Blockers"),("jobs","Jobs"),("events","Eventos")] %}<a class="{% if tab==name %}active{% endif %}" href="?tab={{ name }}">{{ label }}</a>{% endfor %}</nav>
|
||||
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2><form method="post" action="/agents/{{ detail.agent_id }}/reprocess"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="confirm" value="yes"><button class="secondary" type="submit">Reprocessar tudo</button></form></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "-" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "-" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "-" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "-" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "-" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version or "Desconhecida" }} · {{ detail.version_drift|title }}</dd><dt>Último heartbeat</dt><dd title="{{ detail.last_seen_display }}">{{ detail.last_seen_display }} · {{ detail.last_seen_age }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}</dd><dt>IDs técnicos</dt><dd>Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}</dd></dl></article>
|
||||
<article class="panel"><div class="panel-title"><h2>Eventos recentes</h2><a href="#eventos">Ver eventos</a></div>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_display }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.job_id %} · Job {{ event.job_id }}{% endif %}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}</small></span><em class="severity {{ event.severity|lower }}">Severidade: {{ event.severity }}</em></div>{% else %}<p class="empty-state">Nenhum evento.</p>{% endfor %}</article></section>
|
||||
<section class="panel" id="eventos"><div class="panel-title"><div><h2>Eventos operacionais</h2><p class="muted">Filtros aplicados no servidor.</p></div></div><form class="filters compact-filters" method="get"><input type="hidden" name="tab" value="events"><label>Severidade<select name="severity"><option value="">Todas</option>{% for value in ["INFO","WARNING","ERROR","CRITICAL"] %}<option value="{{ value }}" {% if severity==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de evento<input name="event_type" value="{{ event_type }}"></label><label>Job ID<input name="job_id" value="{{ job_id }}" inputmode="numeric"></label><label>Produto<input name="product_code" value="{{ product_code }}"></label><button class="secondary" type="submit">Filtrar eventos</button></form><nav class="pagination" aria-label="Paginação de eventos">{% if event_page > 1 %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page - 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ event_page }}</span><a href="/agents/{{ detail.agent_id }}?event_page={{ event_page + 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Próxima</a></nav></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers">Ver todos</a></div>{% for item in blockers["items"][:8] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.product_code }}</b> · {{ item.field }}<small>{{ item.reason }} · {{ item.age }}</small></span><em class="badge blocked">{{ item.blocker_type }}</em></a>{% else %}<p class="empty-state">Nenhum blocker aberto.</p>{% endfor %}</section>
|
||||
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2><a class="secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "-" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "-" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "-" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "-" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "-" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version or "Desconhecida" }} · {{ detail.version_drift|title }}</dd><dt>Último heartbeat</dt><dd title="{{ detail.last_seen_display }}">{{ detail.last_seen_display }} · {{ detail.last_seen_age }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}</dd><dt>IDs técnicos</dt><dd>Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}</dd></dl></article>
|
||||
<article class="panel"><div class="panel-title"><h2>Eventos recentes</h2><a href="#eventos">Ver eventos</a></div>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_display }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.job_id %} · <a href="/jobs?job_id={{ event.job_id }}">Job {{ event.job_id }}</a>{% endif %}{% if event.product_code %} · <a href="/blockers?agent_id={{ detail.agent_id }}&product_code={{ event.product_code|urlencode }}">Produto {{ event.product_code }}</a>{% endif %}</small></span><em class="severity {{ event.severity|lower }}">Severidade: {{ event.severity }}</em></div>{% else %}<p class="empty-state">Nenhum evento.</p>{% endfor %}</article></section>
|
||||
<section class="panel" id="eventos"><div class="panel-title"><div><h2>Eventos operacionais</h2><p class="muted">Filtros aplicados no servidor.</p></div></div><form class="filters compact-filters" method="get"><input type="hidden" name="tab" value="events"><label>Severidade<select name="severity"><option value="">Todas</option>{% for value in ["INFO","WARNING","ERROR","CRITICAL"] %}<option value="{{ value }}" {% if severity==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de evento<input name="event_type" value="{{ event_type }}"></label><label>Job ID<input name="job_id" value="{{ job_id }}" inputmode="numeric"></label><label>Produto<input name="product_code" value="{{ product_code }}"></label><label>Desde<input type="date" name="since" value="{{ since }}"></label><label>Até<input type="date" name="until" value="{{ until }}"></label><button class="secondary" type="submit">Filtrar eventos</button></form><nav class="pagination" aria-label="Paginação de eventos">{% if event_page > 1 %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page - 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ event_page }}</span>{% if has_more %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page + 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Próxima</a>{% endif %}</nav></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers?agent_id={{ detail.agent_id }}">Ver todos</a></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ blocker_breakdown[key] }}</b></div>{% endfor %}</div>{% for item in blockers["items"][:8] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.product_code }}</b> · {{ item.field }}<small>{{ item.reason }} · {{ item.age }}</small></span><em class="badge blocked">{{ item.blocker_type }}</em></a>{% else %}<p class="empty-state">Nenhum blocker aberto.</p>{% endfor %}</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{% 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.safe_blocked_products }}</strong></article><article class="metric"><span>Clientes afetados</span><strong>{{ 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"><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 abertos</h2><span class="muted">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 }}</span></div><div class="bar-grid">{% 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 %}</div></section>
|
||||
<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_display }}">{{ 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</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>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}</article>
|
||||
<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_age }}">{{ job.created_display }}<small>{{ job.created_age }}</small></td><td title="{{ job.finished_age }}">{{ 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Confirmar reprocessamento | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">AÇÃO OPERACIONAL</p>
|
||||
<h1>Confirmar reprocessamento</h1>
|
||||
<p>O reprocessamento completo será enfileirado para esta instalação.</p>
|
||||
{% if error %}<p class="error" role="alert">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="/agents/{{ agent_id }}/reprocess">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label><input type="checkbox" name="confirm" value="yes" required> Confirmo reprocessar todos os produtos desta instalação.</label>
|
||||
<p><button type="submit">Confirmar e enfileirar</button> <a href="/agents/{{ agent_id }}">Cancelar</a></p>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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 '<span>Blockers abertos</span><strong>56</strong>' in open_page.text
|
||||
assert '<span>Produtos afetados</span><strong>55</strong>' in open_page.text
|
||||
assert '<span>Clientes afetados</span><strong>1</strong>' in open_page.text
|
||||
resolved_page = client.get("/blockers?status=RESOLVED")
|
||||
assert '<span>Blockers abertos</span><strong>0</strong>' in resolved_page.text
|
||||
assert '<span>Produtos afetados</span><strong>1</strong>' in resolved_page.text
|
||||
all_page = client.get("/blockers?status=ALL")
|
||||
assert '<span>Blockers abertos</span><strong>57</strong>' in all_page.text
|
||||
assert '<span>Produtos afetados</span><strong>57</strong>' 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 ">CFOP</span><b>1</b>" in agent_response.text
|
||||
assert ">ST</span><b>0</b>" 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
|
||||
|
||||
Reference in New Issue
Block a user