diff --git a/central/app/web.py b/central/app/web.py index 16a438e..60ce3de 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -1,6 +1,7 @@ """Server-rendered operator pages for the GTI Fiscal control panel.""" -from urllib.parse import parse_qs +from datetime import datetime +from urllib.parse import parse_qs, urlencode from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse @@ -12,6 +13,7 @@ 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 .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 .jobs import create_job from .audit import record_audit @@ -28,6 +30,9 @@ def router(session_factory): 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) + def query_base(**values): + return urlencode({key: value for key, value in values.items() if value not in (None, "")}) + @routes.get("/login", response_class=HTMLResponse) def login_page(request: Request): return render(request, "login.html", {"error": None}) @@ -75,14 +80,15 @@ def router(session_factory): finally: session.close() @routes.get("/agents", response_class=HTMLResponse) - def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None): + def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None, sort: str = "display_name"): session, auth_data, redirect = page_auth(request) if redirect: return redirect - try: return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health), "auth": auth_data, "search": search or "", "health_filter": health or ""}) + try: + return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health, sort=sort), "auth": auth_data, "search": search or "", "health_filter": health or "", "sort": sort, "query_base": query_base(search=search, health=health, sort=sort)}) finally: session.close() @routes.get("/agents/{agent_id}", response_class=HTMLResponse) - def agent_detail(request: Request, agent_id: int, tab: str = "overview"): + 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): session, auth_data, redirect = page_auth(request) if redirect: return redirect try: @@ -90,7 +96,10 @@ def router(session_factory): 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)) - 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": list_panel_events(session, installation.id, page=1, page_size=25), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")}) + 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) + 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", "")}) finally: session.close() @routes.post("/agents/{agent_id}/reprocess") @@ -112,10 +121,15 @@ def router(session_factory): finally: session.close() @routes.get("/blockers", response_class=HTMLResponse) - def blockers(request: Request, page: int = 1, blocker_type: 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): session, auth_data, redirect = page_auth(request) if redirect: return redirect - try: return render(request, "blockers.html", {"result": list_blockers(session, page=page, page_size=50, blocker_type=blocker_type), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")}) + 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)}) finally: session.close() @routes.get("/blockers/{blocker_id}", response_class=HTMLResponse) @@ -154,10 +168,10 @@ def router(session_factory): session.close() @routes.get("/jobs", response_class=HTMLResponse) - def jobs(request: Request, page: int = 1, status: str | None = None): + def jobs(request: Request, page: int = 1, status: str | None = None, search: str | None = None, kind: str | 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), "auth": auth_data}) + 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)}) finally: session.close() return routes diff --git a/central/static/control-panel.css b/central/static/control-panel.css index 72a949e..35f4b00 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -1 +1,123 @@ -:root{--ink:#172126;--muted:#6d7c82;--paper:#f5f7f6;--card:#fff;--line:#dce4e1;--accent:#0e766e;--accent-soft:#dff1ec;--danger:#b64242;--warn:#a86b18;--shadow:0 10px 30px #183b3210}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 Inter,ui-sans-serif,system-ui,sans-serif}.topbar{height:68px;background:#112c2b;color:#f5fffb;display:flex;align-items:center;padding:0 clamp(18px,4vw,64px);gap:42px}.brand{font-weight:900;letter-spacing:.1em;color:#fff;text-decoration:none;font-size:17px}.brand span{color:#8ed6bd}.topbar nav{display:flex;gap:22px;flex:1}.topbar nav a{color:#c2d9d2;text-decoration:none}.topbar nav a:hover{color:#fff}.ghost{background:transparent;border:1px solid #ffffff44;color:#fff;padding:7px 13px;border-radius:8px}.shell{max-width:1440px;margin:0 auto;padding:38px clamp(18px,4vw,64px) 70px}.page-heading,.detail-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px;margin-bottom:26px}h1,h2,p{margin-top:0}h1{font-size:clamp(28px,3vw,42px);line-height:1.05;margin-bottom:8px;letter-spacing:-.04em}h2{font-size:17px;margin-bottom:16px}.eyebrow{color:var(--accent);font-size:11px;font-weight:800;letter-spacing:.14em;margin-bottom:9px}.muted,small{color:var(--muted)}small{display:block;font-size:12px}.live-dot{color:var(--accent);font-size:12px}.cards{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:20px}.cards.compact{grid-template-columns:repeat(4,1fr)}.metric,.panel{background:var(--card);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:14px}.metric{padding:17px}.metric span{color:var(--muted);font-size:12px}.metric strong{font-size:29px;display:block;margin-top:8px;letter-spacing:-.04em}.split{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px}.panel{padding:22px;margin-bottom:20px}.panel-title{display:flex;justify-content:space-between;align-items:center;gap:12px}.panel-title a{color:var(--accent);font-size:12px}.coverage{display:flex;gap:clamp(20px,5vw,70px)}.coverage strong{font-size:28px;display:block}.coverage span{color:var(--muted);font-size:12px}.bar-row,.list-row,.event-row{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding:11px 0}.bar-row:first-of-type,.list-row:first-of-type{border-top:0}.list-row{color:inherit;text-decoration:none}.list-row span{min-width:0}.list-row b,.list-row small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{display:inline-block;padding:4px 8px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.04em;background:#e9eeee;color:#526166}.badge.large{padding:8px 12px}.badge.online,.badge.completed{background:var(--accent-soft);color:#126d5b}.badge.degraded,.badge.warning,.badge.running{background:#fff0d6;color:var(--warn)}.badge.offline,.badge.failed,.badge.error{background:#ffe3e3;color:var(--danger)}.badge.blocked{background:#eee8fa;color:#7553a3}.table-wrap{overflow:auto;padding:0}table{width:100%;border-collapse:collapse;min-width:840px}th,td{text-align:left;padding:14px 18px;border-bottom:1px solid var(--line);vertical-align:top}th{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);background:#f9fbfa}td a{color:var(--ink);text-decoration:none}.filters{display:flex;gap:8px;flex-wrap:wrap}input,select{border:1px solid var(--line);border-radius:8px;padding:9px 11px;background:#fff;color:var(--ink)}button{cursor:pointer;border:0;border-radius:8px;padding:9px 14px;font-weight:700}.primary{background:var(--accent);color:white}.secondary{background:var(--accent-soft);color:#126d5b}.detail-head{align-items:center}.tabs{display:flex;gap:5px;margin-bottom:20px;border-bottom:1px solid var(--line)}.tabs a{padding:10px 14px;text-decoration:none;color:var(--muted);text-transform:capitalize}.tabs a.active{color:var(--accent);border-bottom:2px solid var(--accent);font-weight:800}.facts{display:grid;grid-template-columns:130px 1fr;gap:10px;margin:0}.facts dt{color:var(--muted)}.facts dd{margin:0;font-weight:600}.event-row{gap:12px;justify-content:flex-start}.event-time{color:var(--muted);font-size:11px;min-width:150px}.severity{font-size:10px;font-weight:800;margin-left:auto}.severity.error,.severity.critical{color:var(--danger)}.login-page{display:grid;place-items:center;min-height:100vh;background:#112c2b}.login-card{background:#fff;padding:36px;border-radius:18px;width:min(410px,calc(100% - 32px));box-shadow:var(--shadow)}.login-card label{display:block;margin:16px 0 6px;font-weight:700}.login-card input{width:100%;margin-top:6px}.login-card button{width:100%;margin-top:20px}.alert{padding:10px;border-radius:8px}.alert.error{color:var(--danger);background:#ffe3e3}.error-text{color:var(--danger)}@media(max-width:900px){.cards{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.topbar{gap:16px;padding:0 18px}.topbar nav{gap:11px;overflow:auto}.topbar nav a{white-space:nowrap}}@media(max-width:560px){.cards,.cards.compact{grid-template-columns:repeat(2,1fr)}.page-heading,.detail-head{display:block}.page-heading .filters{margin-top:18px}.filters input,.filters select,.filters button{width:100%}.shell{padding-top:26px}.panel{padding:16px}.event-time{min-width:0}.event-row{display:grid;grid-template-columns:1fr auto}.event-row span:nth-child(2){grid-column:1 / -1}} +:root { + --ink: #172126; + --muted: #5f7077; + --paper: #f5f7f6; + --card: #ffffff; + --line: #d5e1dd; + --accent: #0b6f67; + --accent-soft: #dff1ec; + --danger: #a83232; + --warn: #8b5a12; + --shadow: 0 10px 30px #183b3210; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--paper); color: var(--ink); font: 14px/1.5 Inter, ui-sans-serif, system-ui, sans-serif; } +a { color: var(--accent); } +.topbar { min-height: 68px; background: #112c2b; color: #f5fffb; display: flex; align-items: center; padding: 0 clamp(18px, 4vw, 64px); gap: 42px; } +.brand { font-weight: 900; letter-spacing: .1em; color: #fff; text-decoration: none; font-size: 17px; } +.brand span { color: #8ed6bd; } +.topbar nav { display: flex; flex-wrap: wrap; gap: 22px; flex: 1; } +.topbar nav a { color: #c2d9d2; text-decoration: none; } +.topbar nav a:hover, .topbar nav a:focus-visible { color: #fff; } +.ghost, button { cursor: pointer; } +.ghost { background: transparent; border: 1px solid #ffffff66; color: #fff; padding: 7px 13px; border-radius: 8px; } +.shell { max-width: 1440px; margin: 0 auto; padding: 38px clamp(18px, 4vw, 64px) 70px; } +.page-heading, .detail-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; margin-bottom: 26px; } +h1, h2, p { margin-top: 0; } +h1 { font-size: clamp(28px, 3vw, 42px); line-height: 1.05; margin-bottom: 8px; letter-spacing: -.04em; } +h2 { font-size: 17px; margin-bottom: 16px; } +.eyebrow { color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .14em; margin-bottom: 9px; } +.muted, small { color: var(--muted); } +small { display: block; font-size: 12px; } +.live-dot { color: var(--accent); font-size: 12px; white-space: nowrap; } +.cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 20px; } +.cards.compact { grid-template-columns: repeat(4, 1fr); } +.metric, .panel { background: var(--card); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: 14px; } +.metric { padding: 17px; } +.metric span { color: var(--muted); font-size: 12px; } +.metric strong { font-size: 29px; display: block; margin-top: 8px; letter-spacing: -.04em; } +.metric-inline strong { display: inline; } +.split { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; } +.panel { padding: 22px; margin-bottom: 20px; } +.panel-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.panel-title h2 { margin-bottom: 0; } +.panel-title a { color: var(--accent); font-size: 12px; } +.coverage { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.coverage div { border-left: 3px solid var(--accent-soft); padding-left: 12px; } +.coverage strong { display: block; font-size: 23px; } +.coverage span { color: var(--muted); font-size: 12px; } +.bar-row { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); padding: 8px 0; } +.bar-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 24px; } +.list-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line); text-decoration: none; color: inherit; } +.list-row:last-child { border-bottom: 0; } +.list-row:hover { background: #f7fbf9; } +.empty-state { color: var(--muted); border: 1px dashed var(--line); padding: 18px; margin: 0; } +.badge, .severity { display: inline-flex; align-items: center; border: 1px solid currentColor; border-radius: 999px; padding: 3px 8px; font-size: 11px; font-style: normal; font-weight: 800; white-space: nowrap; } +.badge.large { padding: 7px 12px; } +.badge-stack { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.online, .fiscal-complete, .status-completed { color: #176646; background: #e5f5eb; } +.degraded, .warning, .fiscal-incomplete, .status-pending, .status-running, .priority-high { color: var(--warn); background: #fff1d5; } +.offline, .unknown { color: #53656c; background: #e9eef0; } +.failed, .priority-critical, .status-failed { color: var(--danger); background: #fde7e7; } +.blocked, .priority-normal { color: #6d4c1c; background: #fff3d8; } +.priority-low { color: #53656c; background: #e9eef0; } +.fiscal-blocked { color: #7d3c13; background: #ffead6; } +.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; } +.filters label { color: var(--muted); display: grid; gap: 4px; font-size: 12px; font-weight: 700; } +.filters input, .filters select, .filters textarea, input, select, textarea { border: 1px solid #b8c9c4; background: #fff; border-radius: 7px; color: var(--ink); font: inherit; padding: 8px 10px; min-height: 38px; } +.filters input, .filters select { min-width: 150px; } +.filters button { align-self: end; } +.secondary, button:not(.ghost) { border: 1px solid var(--accent); background: var(--accent); color: #fff; padding: 8px 13px; border-radius: 7px; text-decoration: none; font: inherit; } +.secondary:hover, button:not(.ghost):hover { background: #095a54; } +.blocker-filters label { flex: 1 1 170px; } +.compact-filters { margin-top: 12px; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; min-width: 960px; } +caption { text-align: left; font-size: 17px; font-weight: 800; margin-bottom: 12px; } +th { color: var(--muted); font-size: 11px; letter-spacing: .04em; text-align: left; text-transform: uppercase; } +th, td { border-bottom: 1px solid var(--line); padding: 12px 10px; vertical-align: top; } +tbody tr:last-child td { border-bottom: 0; } +td a { text-decoration: none; } +.pagination { display: flex; justify-content: center; align-items: center; gap: 18px; margin: 4px 0 22px; color: var(--muted); } +.pagination a { border: 1px solid var(--line); background: #fff; border-radius: 7px; padding: 6px 10px; text-decoration: none; } +.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--line); } +.tabs a { padding: 9px 13px; text-decoration: none; color: var(--muted); } +.tabs a.active { color: var(--accent); border-bottom: 3px solid var(--accent); font-weight: 800; } +.facts { display: grid; grid-template-columns: minmax(130px, .7fr) 1fr; gap: 10px 18px; margin: 0; } +.facts dt { color: var(--muted); font-weight: 700; } +.facts dd { margin: 0; overflow-wrap: anywhere; } +.event-row { display: grid; grid-template-columns: 120px 1fr auto; align-items: start; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--line); } +.event-row:last-child { border-bottom: 0; } +.event-time { color: var(--muted); font-size: 12px; } +.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; } +.form-grid label { display: grid; gap: 5px; color: var(--muted); font-weight: 700; } +.form-grid .wide { grid-column: 1 / -1; } +.form-grid select, .form-grid input, .form-grid textarea { width: 100%; } +.form-grid textarea { min-height: 100px; resize: vertical; } +.error, .error-text { color: var(--danger); } +.evidence { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.4 ui-monospace, monospace; } + +:focus-visible { outline: 3px solid #f1ae28; outline-offset: 2px; } + +@media (max-width: 1100px) { + .cards { grid-template-columns: repeat(3, 1fr); } + .bar-grid { grid-template-columns: repeat(2, 1fr); } + .page-heading, .detail-head { align-items: flex-start; flex-direction: column; } + .badge-stack { justify-content: flex-start; } +} +@media (max-width: 768px) { + .topbar { align-items: flex-start; flex-wrap: wrap; gap: 14px 24px; padding-block: 16px; } + .topbar nav { order: 3; flex-basis: 100%; } + .shell { padding-top: 26px; } + .cards, .cards.compact, .split { grid-template-columns: 1fr 1fr; } + .coverage { grid-template-columns: repeat(2, 1fr); } + .form-grid { grid-template-columns: 1fr; } + .form-grid .wide { grid-column: auto; } + .event-row { grid-template-columns: 1fr; gap: 4px; } +} +@media (max-width: 520px) { + .cards, .cards.compact, .split, .bar-grid { grid-template-columns: 1fr; } + .filters label, .filters input, .filters select, .filters button { width: 100%; } + .filters button { margin-top: 2px; } +} diff --git a/central/static/control-panel.js b/central/static/control-panel.js index 6543b54..d157cb7 100644 --- a/central/static/control-panel.js +++ b/central/static/control-panel.js @@ -1,4 +1,3 @@ -document.addEventListener("click", event => { - const action = event.target.closest("[data-confirm]"); - if (action && !window.confirm(action.dataset.confirm)) event.preventDefault(); +document.addEventListener("DOMContentLoaded", () => { + document.documentElement.classList.add("js"); }); diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 1dcee3a..c3c8927 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -1,9 +1,11 @@ {% extends "base.html" %} {% block title %}{{ detail.display_name }} | GTI Fiscal{% endblock %} {% block content %} -

{{ detail.client }} / INSTALAÇÃO

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

{{ detail.hostname or "hostname indisponível" }} · Agent {{ detail.agent_id }} · Installation {{ detail.installation_id }}

{{ detail.health }}
-
Produtos{{ detail.total_products }}
Completos{{ detail.fiscally_complete }}
Safe blocked{{ detail.safe_blocked }}
Coverage{{ "%.1f"|format(detail.coverage_percent) }}%
- -

Contexto da empresa

Empresa
{{ detail.company_name or "-" }}
UF / CRT
{{ detail.company_uf or "-" }} / {{ detail.company_crt or "-" }}
Firebird
{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}
Último heartbeat
{{ detail.last_seen_at or "-" }}
Último job
{{ detail.last_job_status or "-" }} · {{ detail.last_job_at or "-" }}

Eventos recentes

{% for event in events[:8] %}
{{ event.occurred_at }}{{ event.event_type }}{{ event.message }}{{ event.severity }}
{% else %}

Nenhum evento.

{% endfor %}
-

Blockers abertos

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

Nenhum blocker aberto.

{% endfor %}
+

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

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

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

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

Contexto da empresa

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

Eventos recentes

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

Nenhum evento.

{% endfor %}
+

Eventos operacionais

Filtros aplicados no servidor.

+

Blockers abertos

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

Nenhum blocker aberto.

{% endfor %}
{% endblock %} diff --git a/central/templates/agents.html b/central/templates/agents.html index 9c3c0d2..35a92fe 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -1 +1,7 @@ -{% extends "base.html" %}{% block title %}Agents | GTI Fiscal{% endblock %}{% block content %}

INVENTÁRIO OPERACIONAL

Agents / Servidores

{% for item in result["items"] %}{% else %}{% endfor %}
Cliente / instalaçãoServidorEmpresaHealthVersãoProdutosCompleteBlockedLast 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.
{% endblock %} +{% extends "base.html" %} +{% block title %}Agents | GTI Fiscal{% endblock %} +{% block content %} +

INVENTÁRIO OPERACIONAL

Agents / Servidores

Identidade do cliente primeiro; IDs técnicos ficam no detalhe.

+
{% for item in result["items"] %}{% else %}{% endfor %}
Instalações monitoradas
ClienteNome de exibiçãoHostnameSaúde técnicaStatus fiscalCobertura fiscalProdutosVersãoÚltimo heartbeat
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}ID técnico: {{ item.installation_id }}{{ item.hostname or "-" }}Saúde técnica: {{ item.technical_health_label }}Status fiscal: {{ item.fiscal_status_label }}{% if item.safe_blocked %}{{ item.safe_blocked }} bloqueados com segurança{% endif %}{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%{{ item.fiscally_complete }} / {{ item.total_products }}{{ item.total_products }}{{ item.failed_products }} falhas{{ item.agent_version or "Desconhecida" }}{{ item.version_drift|title }}{{ item.last_seen_display }}{{ item.last_seen_age }}
Nenhuma instalação encontrada.
+ +{% endblock %} diff --git a/central/templates/base.html b/central/templates/base.html index 2385cf1..a0fb7b9 100644 --- a/central/templates/base.html +++ b/central/templates/base.html @@ -1,8 +1,18 @@ -{% block title %}GTI Fiscal Control Panel{% endblock %} + + + + {% block title %}GTI Fiscal Control Panel{% endblock %} + + -
GTI FISCAL{% if auth %}
{% endif %}
+
+ GTI FISCAL + + {% if auth %}
{% endif %} +
{% block content %}{% endblock %}
- + + diff --git a/central/templates/blocker_detail.html b/central/templates/blocker_detail.html index a0f03cd..9bc6a3a 100644 --- a/central/templates/blocker_detail.html +++ b/central/templates/blocker_detail.html @@ -1,11 +1,12 @@ {% extends "base.html" %} {% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %} {% block content %} -

RESOLUÇÃO OPERACIONAL

Blocker {{ detail.id }}

{{ detail.client or detail.tenant_id }} · {{ detail.product_code }}

{{ detail.blocker_type }}
+

RESOLUÇÃO OPERACIONAL

Blocker {{ detail.id }}

{{ detail.client or "Cliente não identificado" }} · {{ detail.product_code }}

Fiscal: {{ detail.blocker_type }}Prioridade: {{ detail.priority }}
{% if error %}

{{ error }}

{% endif %}
-

Detalhes do blocker

Cliente
{{ detail.client or "-" }}
Servidor
{{ detail.server.hostname or "-" }}
Produto
{{ detail.product_code }}
Descrição
{{ detail.description or "-" }}
Campo
{{ detail.field or "-" }}
Tipo
{{ detail.blocker_type }}
Motivo
{{ detail.reason }}
Evidência
{{ detail.evidence }}
+

Detalhes do blocker

Cliente
{{ detail.client or "-" }}
Instalação
{{ detail.installation.display_name or "-" }}
Servidor
{{ detail.server.hostname or "-" }}
Produto
{{ detail.product_code }}
Descrição
{{ detail.description or "-" }}
Campo
{{ detail.field or "OTHER" }}
Tipo
{{ detail.blocker_type }}
Motivo
{{ detail.reason }}
Idade
{{ detail.age }}
Última ocorrência
{{ detail.last_seen_display }}
Evidência
{{ detail.evidence|tojson(indent=2) }}

Contexto atual

Valor atual por chave persistida para esta empresa.

{% for entry in context_entries %}
{{ entry.key }}{{ entry.scope }} / {{ entry.scope_key or "empresa" }}{{ entry.value }}
{% else %}

Nenhum valor definido.

{% endfor %}
-

Salvar contexto e reprocessar

Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.

Cancelar
+

Salvar contexto e reprocessar

Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.

Cancelar
+

Eventos relacionados

{% for event in detail.related_events[:10] %}
{{ event.severity }}{{ event.event_type }}{{ event.message }}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}
{% else %}

Nenhum evento relacionado.

{% endfor %}

Jobs relacionados

{% for job in detail.related_jobs[:10] %}
Job {{ job.job_id }}{{ job.kind }} · {{ job.status }}{{ job.duration }}
{% else %}

Nenhum job relacionado.

{% endfor %}
{% endblock %} diff --git a/central/templates/blockers.html b/central/templates/blockers.html index 614bcdc..b486d64 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -1 +1,10 @@ -{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}

FILA FISCAL

Blockers globais

{% for item in result["items"] %}{% else %}{% endfor %}
ClienteProdutoCampoTipoMotivoÚltima ocorrênciaAçã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 }} retriesResolver com contexto
Nenhum blocker aberto.
{% endblock %} +{% extends "base.html" %} +{% block title %}Blockers | GTI Fiscal{% endblock %} +{% block content %} +

FILA FISCAL

Blockers globais

Problemas fiscais separados de falhas técnicas.

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

Distribuição dos blockers abertos

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

OPERAÇÃO CENTRAL

Qual cliente precisa de atenção?

● Atualizado pelo snapshot
{% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("safe_blocked","Safe blocked")] %}
{{ label }}{{ summary[key] }}
{% endfor %}

Cobertura fiscal

Ver instalações
{{ summary.total_products }}Produtos
{{ summary.fiscally_complete }}Completos
{{ summary.failed_products }}Falhas técnicas

Principais blockers

Abrir fila
{% for key,value in summary.blockers.items() %}
{{ key }}{{ value }}
{% else %}

Nenhum blocker aberto.

{% endfor %}
{% if summary.stale_agents %}

Heartbeat atrasado

Filtrar Agents
{% for item in summary.stale_agents[:8] %}{{ item.client }} · {{ item.display_name }}{{ item.hostname or "hostname indisponível" }}OFFLINE{% endfor %}
{% endif %}{% endblock %} +{% extends "base.html" %} +{% block title %}Resumo | GTI Fiscal{% endblock %} +{% block content %} +

OPERAÇÃO CENTRAL

Qual cliente precisa de atenção?

Fila operacional baseada no último snapshot recebido.

Atualizado pelo snapshot
+
+ {% for key,label in [("agents_total","Agents total"),("online","Online"),("degraded","Degraded"),("offline","Offline"),("failed","Failed"),("unknown","Unknown")] %}
{{ label }}{{ summary[key] }}
{% endfor %} +
+
+

Métricas fiscais

Ver instalações
{{ summary.safe_blocked_products }}Produtos bloqueados
{{ summary.open_blockers }}Blockers abertos
{{ summary.total_products }}Produtos avaliados
{{ summary.fiscally_complete }}Completos
+

Breakdown dos blockers

Abrir fila
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
{{ key }}{{ summary.open_blocker_breakdown[key] }}
{% endfor %}
+
+

FILA DE AÇÃO

ATENÇÃO NECESSÁRIA

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

Nenhum item requer atenção imediata.

{% endfor %}
+
+

Jobs com falha

Ver jobs
{% if summary.failed_jobs %}

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

{% else %}

Nenhum job com falha.

{% endif %}
+

Agents desatualizados

Ver agents
{% for item in summary.outdated_agents[:10] %}{{ item.client }} · {{ item.display_name }}{{ item.hostname or "hostname indisponível" }} · {{ item.agent_version or "versão desconhecida" }}Desatualizado{% else %}

Nenhum agent desatualizado.

{% endfor %}
+
+{% endblock %} diff --git a/central/templates/jobs.html b/central/templates/jobs.html index f2b9ffb..1919717 100644 --- a/central/templates/jobs.html +++ b/central/templates/jobs.html @@ -1 +1,8 @@ -{% extends "base.html" %}{% block title %}Jobs | GTI Fiscal{% endblock %}{% block content %}

EXECUÇÕES

Jobs globais

{% for item in result["items"] %}{% else %}{% endfor %}
JobAgentKindStatusCreatedFinishedError
#{{ item.job_id }}{{ item.agent_id }}{{ item.kind }}{{ item.status }}{{ item.created_at }}{{ item.finished_at or "-" }}{{ item.error or "-" }}
Nenhum job encontrado.
{% endblock %} +{% extends "base.html" %} +{% block title %}Jobs | GTI Fiscal{% endblock %} +{% block content %} +

EXECUÇÕES

Jobs globais

Acompanhe o que está em andamento e onde falhou.

+
+
{% for item in result["items"] %}{% else %}{% endfor %}
Execuções recentes
ClienteNome de exibiçãoHostnameJob IDTipoStatusCriadoFinalizadoDuraçãoErro
{{ item.client or "Cliente não identificado" }}{{ item.display_name or "-" }}{{ item.hostname or "-" }}#{{ item.job_id }}Agent técnico {{ item.agent_id }}{{ item.kind }}Status: {{ item.status }}{{ item.created_display }}{{ item.finished_display }}{{ item.duration }}{{ item.error or "-" }}
Nenhum job encontrado para os filtros atuais.
+ +{% endblock %} diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py new file mode 100644 index 0000000..f4e0a05 --- /dev/null +++ b/central/tests/test_control_panel_pages.py @@ -0,0 +1,155 @@ +import re +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient +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.tests.test_control_plane import seed, setup_db + + +def _panel_client(): + session_factory = setup_db() + agent_id, _ = seed(session_factory) + now = datetime(2026, 9, 22, 19, 11, tzinfo=timezone.utc) + with session_factory() as session: + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) + agent = session.get(Agent, agent_id) + agent.agent_version = "0.9.0" + snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id)) + snapshot.total_products = 50 + snapshot.fiscally_complete = 49 + snapshot.coverage_percent = 98 + job = Job( + agent_id=agent_id, + kind="REPROCESS_PRODUCTS", + status="RUNNING", + created_at=now - timedelta(minutes=3), + started_at=now - timedelta(minutes=2), + payload={"codigos": ["P-001"]}, + ) + session.add(job) + session.flush() + blocker = upsert_blocker( + session, + installation.tenant_id, + { + "company": "Acme", + "product_code": "P-001", + "description": "Produto de teste", + "blocker_type": "BLOCKED_MISSING_EVIDENCE", + "field": "CFOP", + "reason_code": "MISSING_CONTEXT", + "input_fingerprint": "page-test", + "evidence_summary": {"source": "snapshot"}, + }, + installation_id=installation.id, + ) + record_event( + session, + tenant_id=installation.tenant_id, + installation_id=installation.id, + agent_id=agent_id, + severity="WARNING", + event_type="PRODUCT_BLOCKED", + message="Contexto fiscal ausente", + job_id=job.id, + product_code="P-001", + blocker_id=blocker.id, + occurred_at=now, + ) + session.commit() + + client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)) + 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 + + +def test_dashboard_renders_operational_queue_labels_and_human_dates(): + client, _, _ = _panel_client() + + response = client.get("/") + + assert response.status_code == 200 + for label in ("Agents total", "Online", "Degraded", "Offline", "Failed", "Unknown"): + assert label in response.text + assert "Produtos bloqueados" in response.text + assert "Blockers abertos" in response.text + assert "ATENÇÃO NECESSÁRIA" in response.text + assert "Ver todos" in response.text + assert "Jobs com falha" in response.text + assert "Agents desatualizados" in response.text + assert not re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", response.text) + + +def test_agents_render_separate_health_and_fiscal_status_with_server_sort(): + client, _, _ = _panel_client() + + response = client.get("/agents?sort=worst_coverage&health=ONLINE&search=Acme") + + assert response.status_code == 200 + for label in ("Cliente", "Nome de exibição", "Hostname", "Saúde técnica", "Status fiscal", "Cobertura fiscal", "Versão"): + assert label in response.text + assert "Acme ERP" in response.text + assert "srv-01" in response.text + assert "98,0%" in response.text + assert 'name="health"' in response.text + assert 'value="Acme"' in response.text + assert 'value="worst_coverage"' in response.text + + +def test_jobs_render_client_identity_duration_and_preserve_filters(): + client, _, _ = _panel_client() + + response = client.get("/jobs?status=RUNNING&search=Acme") + + assert response.status_code == 200 + for label in ("Cliente", "Nome de exibição", "Hostname", "Job ID", "Tipo", "Status", "Criado", "Finalizado", "Duração", "Erro"): + assert label in response.text + assert "Acme" in response.text + assert "srv-01" in response.text + assert "em andamento" in response.text + assert 'value="Acme"' in response.text + assert '