diff --git a/central/app/api.py b/central/app/api.py
index bacb32e..c1283b3 100644
--- a/central/app/api.py
+++ b/central/app/api.py
@@ -16,6 +16,8 @@ from .context_keys import validate_entry
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
from .events import record_event
from .installation_identity import compute_agent_health, compute_version_drift, sanitize_registration
+from .control_plane import list_jobs
+from .human_auth import require_human
logger = logging.getLogger("gti_fiscal_hub")
@@ -331,6 +333,15 @@ def router(session_factory, bootstrap_token):
return _job_view(job)
finally: session.close()
+ @routes.get("/jobs/global")
+ def global_jobs(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None):
+ session = session_factory()
+ try:
+ require_human(request, session)
+ return list_jobs(session, page=page, page_size=page_size, status=status, kind=kind)
+ finally:
+ session.close()
+
@routes.get("/jobs/{job_id}")
def get_job(job_id: int, identity=Depends(principal)):
session, agent = identity
diff --git a/central/app/control_api.py b/central/app/control_api.py
index 1819905..0834c13 100644
--- a/central/app/control_api.py
+++ b/central/app/control_api.py
@@ -8,7 +8,7 @@ from sqlalchemy import select
from .audit import record_audit
from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import require_csrf, require_human
-from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Tenant
+from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
from .jobs import create_job
@@ -102,7 +102,8 @@ def router(session_factory):
if kind not in {"REPROCESS_ALL", "REPROCESS_PRODUCTS"}: raise HTTPException(400, "unsupported action")
job_payload = payload.get("payload") or {}
if kind == "REPROCESS_PRODUCTS" and not job_payload.get("codigos"): raise HTTPException(400, "codigos required")
- job = create_job(session, agent, kind, job_payload)
+ existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == kind, Job.status == "PENDING")).all() if candidate.payload == job_payload), None)
+ job = existing or create_job(session, agent, kind, job_payload)
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=kind, target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "request_ip": request.client.host if request.client else None})
session.commit()
return {"job_id": job.id, "status": job.status}
@@ -116,7 +117,8 @@ def router(session_factory):
if payload.get("confirm") is not True or not str(payload.get("context", "")).strip(): raise HTTPException(400, "confirmation and explicit context required")
blocker = session.get(BlockedProduct, blocker_id)
if not blocker: raise HTTPException(404, "blocker not found")
- blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
+ if blocker.resolved_at is None:
+ blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": payload["context"]})
session.commit()
return {"resolved": True, "blocker_id": blocker_id}
diff --git a/central/app/installation_identity.py b/central/app/installation_identity.py
index 450291b..c077e78 100644
--- a/central/app/installation_identity.py
+++ b/central/app/installation_identity.py
@@ -83,12 +83,12 @@ def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental=
return "UNKNOWN", "no heartbeat received"
if last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
- offline_after = settings.agent_offline_after_seconds or settings.agent_heartbeat_interval_seconds * 3
- if (now - last_seen).total_seconds() > offline_after:
- return "OFFLINE", f"last heartbeat exceeds {offline_after}s"
source = supplemental or snapshot
if any(getattr(candidate, "agent_health", None) == "FAILED" or getattr(candidate, "last_status", None) == "FAILED" for candidate in (snapshot, supplemental) if candidate is not None):
return "FAILED", "persistent technical failure"
+ offline_after = settings.agent_offline_after_seconds or settings.agent_heartbeat_interval_seconds * 3
+ if (now - last_seen).total_seconds() > offline_after:
+ return "OFFLINE", f"last heartbeat exceeds {offline_after}s"
if getattr(source, "failed_products", 0) or getattr(source, "last_job_status", "") == "FAILED":
return "DEGRADED", "recent technical or job failure"
return "ONLINE", "recent heartbeat without critical technical failure"
diff --git a/central/app/web.py b/central/app/web.py
index e92ae15..687b0a1 100644
--- a/central/app/web.py
+++ b/central/app/web.py
@@ -10,7 +10,7 @@ from sqlalchemy import select
from .config import settings
from .control_plane import dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import create_session, current_session, require_csrf, verify_password
-from .models import AdminUser, Installation, Agent
+from .models import AdminUser, Installation, Agent, Job
from .jobs import create_job
from .audit import record_audit
@@ -23,7 +23,8 @@ def router(session_factory):
return current_session(request, session)
def render(request, template, context=None, status_code=200):
- return templates.TemplateResponse(template, {"request": request, **(context or {})}, status_code=status_code)
+ values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), **(context or {})}
+ return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code)
@routes.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
@@ -43,10 +44,12 @@ def router(session_factory):
return response
@routes.post("/logout")
- def logout(request: Request):
+ async def logout(request: Request):
with session_factory() as session:
web_session, admin = current_session(request, session)
if web_session:
+ values = await form_values(request)
+ require_csrf(web_session, values.get("csrf_token"))
session.delete(web_session); session.commit()
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(settings.web_cookie_name); response.delete_cookie("gti_csrf")
@@ -99,7 +102,8 @@ def router(session_factory):
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)
- job = create_job(session, agent, "REPROCESS_ALL", {})
+ existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == "REPROCESS_ALL", Job.status == "PENDING")).all() if candidate.payload == {}), None)
+ job = existing or create_job(session, agent, "REPROCESS_ALL", {})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="REPROCESS_ALL", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id})
session.commit()
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
diff --git a/central/scripts/generate_control_panel_fixture.py b/central/scripts/generate_control_panel_fixture.py
index b4a7bfe..1c238d9 100644
--- a/central/scripts/generate_control_panel_fixture.py
+++ b/central/scripts/generate_control_panel_fixture.py
@@ -13,16 +13,17 @@ from sqlalchemy.orm import sessionmaker
def generate_fixture(session, agent_count=100, seed=7):
now = datetime.now(timezone.utc)
- states = ["ONLINE"] * 55 + ["DEGRADED"] * 20 + ["OFFLINE"] * 15 + ["FAILED"] * 10
+ states = ["ONLINE"] * 50 + ["DEGRADED"] * 20 + ["OFFLINE"] * 15 + ["FAILED"] * 10 + ["UNKNOWN"] * 5
created = {"agents": 0, "events": 0, "blockers": 0, "jobs": 0}
for index in range(agent_count):
tenant = Tenant(name=f"Fixture Client {index:03d}"); session.add(tenant); session.flush()
- agent = Agent(tenant_id=tenant.id, name=f"fixture-agent-{index:03d}", token_hash=(f"fixture-{index:03d}".ljust(64, "0"))[:64], agent_version="1.0.0rc1"); session.add(agent); session.flush()
+ agent_version = None if index % 17 == 0 else ("0.9.0" if index % 5 == 0 else "1.0.0rc1")
+ agent = Agent(tenant_id=tenant.id, name=f"fixture-agent-{index:03d}", token_hash=(f"fixture-{index:03d}".ljust(64, "0"))[:64], agent_version=agent_version); session.add(agent); session.flush()
state = states[index % len(states)]
- last_seen = now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000)
+ last_seen = None if state == "UNKNOWN" else now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000)
installation = Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")), display_name=f"{tenant.name} - Servidor Principal", customer_code=f"F{index:03d}", hostname=f"srv-{index:03d}", company_name=f"Empresa {index:03d}", company_uf="BA", company_crt="1", last_seen_at=last_seen, last_status="IDLE")
session.add(installation); session.flush()
- snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=index % 9, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED")
+ snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=index % 9, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED")
session.add(snapshot); created["agents"] += 1
for event_index in range(5):
session.add(AgentEvent(tenant_id=tenant.id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if state == "FAILED" and event_index == 0 else "INFO", event_type="JOB_FAILED" if state in {"DEGRADED", "FAILED"} and event_index == 0 else "HEARTBEAT", message="Fixture operational event", structured_context={"fixture": True}, occurred_at=now - timedelta(minutes=event_index)))
diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html
index 38e6b5e..1dcee3a 100644
--- a/central/templates/agent_detail.html
+++ b/central/templates/agent_detail.html
@@ -5,5 +5,5 @@
Nenhum evento. Nenhum blocker aberto. Nenhum blocker aberto.Contexto da empresa
Eventos recentes
{% for event in events[:8] %}Blockers abertos
Ver todosBlockers abertos
Ver todos
INVENTÁRIO OPERACIONAL
| Cliente / instalação | Servidor | Empresa | Health | Versão | Produtos | Complete | Blocked | Last seen |
|---|---|---|---|---|---|---|---|---|
| {{ item.client }}{{ item.display_name }} | {{ item.hostname or "-" }} | {{ item.company_name or item.company_code or "-" }}{{ item.company_uf or "" }} {{ item.company_crt or "" }} | {{ item.health }} | {{ item.agent_version or "UNKNOWN" }}{{ item.version_drift }} | {{ item.total_products }} | {{ item.fiscally_complete }} | {{ item.safe_blocked }} | {{ item.last_seen_at or "Never" }} |
| Nenhuma instalação encontrada. | ||||||||
INVENTÁRIO OPERACIONAL
| Cliente / instalação | Servidor | Empresa | Health | Versão | Produtos | Complete | Blocked | Last seen |
|---|---|---|---|---|---|---|---|---|
| {{ item.client }}{{ item.display_name }} | {{ item.hostname or "-" }} | {{ item.company_name or item.company_code or "-" }}{{ item.company_uf or "" }} {{ item.company_crt or "" }} | {{ item.health }} | {{ item.agent_version or "UNKNOWN" }}{{ item.version_drift }} | {{ item.total_products }} | {{ item.fiscally_complete }} | {{ item.safe_blocked }} | {{ item.last_seen_at or "Never" }} |
| Nenhuma instalação encontrada. | ||||||||
FILA FISCAL
| Cliente | Produto | Campo | Tipo | Motivo | Última ocorrência | Ação |
|---|---|---|---|---|---|---|
| {{ item.client or item.tenant_id }} | {{ item.product_code }}{{ item.description or "" }} | {{ item.field }} | {{ item.blocker_type }} | {{ item.reason }} | {{ item.last_seen_at }}{{ item.retry_count }} retries | |
| Nenhum blocker aberto. | ||||||
FILA FISCAL
| Cliente | Produto | Campo | Tipo | Motivo | Última ocorrência | Ação |
|---|---|---|---|---|---|---|
| {{ item.client or item.tenant_id }} | {{ item.product_code }}{{ item.description or "" }} | {{ item.field }} | {{ item.blocker_type }} | {{ item.reason }} | {{ item.last_seen_at }}{{ item.retry_count }} retries | |
| Nenhum blocker aberto. | ||||||
EXECUÇÕES
| Job | Agent | Kind | Status | Created | Finished | Error |
|---|---|---|---|---|---|---|
| #{{ item.job_id }} | {{ item.agent_id }} | {{ item.kind }} | {{ item.status }} | {{ item.created_at }} | {{ item.finished_at or "-" }} | {{ item.error or "-" }} |
| Nenhum job encontrado. | ||||||
EXECUÇÕES
| Job | Agent | Kind | Status | Created | Finished | Error |
|---|---|---|---|---|---|---|
| #{{ item.job_id }} | {{ item.agent_id }} | {{ item.kind }} | {{ item.status }} | {{ item.created_at }} | {{ item.finished_at or "-" }} | {{ item.error or "-" }} |
| Nenhum job encontrado. | ||||||