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.hostname or "hostname indisponível" }} · Agent {{ detail.agent_id }} · Installation {{ detail.installation_id }}
Nenhum evento.
{% endfor %}Nenhum blocker aberto.
{% endfor %}{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO
{{ detail.hostname or "hostname indisponível" }}
Nenhum evento.
{% endfor %}Filtros aplicados no servidor.
Nenhum blocker aberto.
{% endfor %}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
Identidade do cliente primeiro; IDs técnicos ficam no detalhe.
| Cliente | Nome de exibição | Hostname | Saúde técnica | Status fiscal | Cobertura fiscal | Produtos | Versã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. | ||||||||