diff --git a/central/app/api.py b/central/app/api.py index e9f4a7d..56faa1e 100644 --- a/central/app/api.py +++ b/central/app/api.py @@ -15,7 +15,7 @@ from .blocked_products import list_blockers, resolve_blocker, resolve_product_bl 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 .installation_identity import compute_agent_health, compute_version_drift, reference_agent_version, sanitize_registration from .control_plane import list_jobs from .human_auth import require_human @@ -201,7 +201,7 @@ def router(session_factory, bootstrap_token): snapshot.failed_products = int(coverage.get("failed_products", snapshot.failed_products) or 0) snapshot.coverage_percent = float(coverage.get("coverage_percent", snapshot.coverage_percent) or 0) snapshot.blockers = coverage.get("blockers", snapshot.blockers) or {} - snapshot.version_drift = compute_version_drift(agent.agent_version) + snapshot.version_drift = compute_version_drift(agent.agent_version, reference_agent_version(session)) snapshot.agent_health, snapshot.health_reason = compute_agent_health(installation, supplemental=snapshot) session.add(agent); session.flush() record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="HEARTBEAT", message="Agent heartbeat", context={"status": agent.last_status, "agent_version": agent.agent_version}) diff --git a/central/app/control_api.py b/central/app/control_api.py index d19f24e..9c60f3b 100644 --- a/central/app/control_api.py +++ b/central/app/control_api.py @@ -7,6 +7,7 @@ from sqlalchemy import select from .audit import record_audit from .blocker_resolution import BlockerResolutionUnavailable, resolve_blocker_with_context +from .installation_identity import reference_agent_version 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, Job, Tenant @@ -46,7 +47,7 @@ def router(session_factory): session, _, _ = identity try: installation, agent, tenant, snapshot = get_installation(session, agent_id) - return installation_view(installation, agent, tenant, snapshot) + return installation_view(installation, agent, tenant, snapshot, reference=reference_agent_version(session)) finally: session.close() @routes.get("/agents/{agent_id}/health") diff --git a/central/app/control_plane.py b/central/app/control_plane.py index 5acebc7..45ba38d 100644 --- a/central/app/control_plane.py +++ b/central/app/control_plane.py @@ -10,9 +10,9 @@ from sqlalchemy import Numeric, and_, case, cast, func, or_, select from .config import settings from .events import list_events -from .installation_identity import compute_agent_health, compute_version_drift +from .installation_identity import compute_agent_health, compute_version_drift, outdated_versions, reference_agent_version from .models import Agent, AgentEnrollment, AgentEvent, AgentSnapshot, AuditEvent, BlockedProduct, Installation, Job, Tenant -from .panel_presentation import blocker_reason_label, blocker_type_label, fiscal_status_label, format_age, format_duration, format_ui_datetime, health_label, job_kind_label, job_status_label, operational_priority, priority_label +from .panel_presentation import health_reason_label, blocker_reason_label, blocker_type_label, fiscal_status_label, format_age, format_duration, format_ui_datetime, health_label, job_kind_label, job_status_label, operational_priority, priority_label HEALTH_STATES = ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN") @@ -131,13 +131,13 @@ def _attention_item(item, problem, timestamp, *, rank, link=None, now=None): } -def installation_view(installation, agent, tenant, snapshot=None, *, now=None): +def installation_view(installation, agent, tenant, snapshot=None, *, now=None, reference=None): has_snapshot = snapshot is not None snapshot = snapshot or AgentSnapshot(installation_id=installation.id) health, reason = compute_agent_health(installation, now=now, supplemental=snapshot) - version_drift = snapshot.version_drift - if not version_drift or version_drift == "UNKNOWN": - version_drift = compute_version_drift(agent.agent_version) + # Always recomputed against the current reference release: the value stored on the snapshot was taken at the + # last heartbeat (against whatever was "current" then) and goes stale as soon as a newer release is published. + version_drift = compute_version_drift(agent.agent_version, reference) fiscal_status = _fiscal_status(snapshot) coverage_partition = _coverage_partition(snapshot) if not has_snapshot: @@ -213,6 +213,7 @@ def _installation_query_filters(query, *, client=None, version=None, search=None def list_installations(session, *, page=1, page_size=50, client=None, health=None, version=None, search=None, last_seen_before=None, sort="display_name", scope="active", now=None): page, page_size = _page(page, page_size) now = _utc_now(now) + reference = reference_agent_version(session) query = select(Installation, Agent, Tenant, AgentSnapshot, operational_installation_predicate().label("operational_active")).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id) if scope == "active": query = query.where(operational_installation_predicate()) @@ -220,7 +221,7 @@ def list_installations(session, *, page=1, page_size=50, client=None, health=Non raise ValueError("scope must be active or all") query = _installation_query_filters(query, client=client, version=version, search=search, last_seen_before=last_seen_before) if version == "outdated": - query = query.where(or_(AgentSnapshot.version_drift == "OUTDATED", and_(Agent.agent_version.is_not(None), Agent.agent_version != settings.agent_server_version))) + query = query.where(Agent.agent_version.in_(outdated_versions(session, reference))) health_expression = _health_expression(now) if health: query = query.where(health_expression == health) @@ -229,7 +230,7 @@ def list_installations(session, *, page=1, page_size=50, client=None, health=Non else: ordered = query.order_by(Installation.display_name.asc(), Installation.id.asc()) rows = session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all() - items = [{**installation_view(i, a, t, s, now=now), "operational_active": bool(active)} for i, a, t, s, active in rows] + items = [{**installation_view(i, a, t, s, now=now, reference=reference), "operational_active": bool(active)} for i, a, t, s, active in rows] count_query = select(func.count()).select_from(query.with_only_columns(Installation.id).order_by(None).subquery()) total = session.scalar(count_query) or 0 return {"page": page, "page_size": page_size, "items": items, "total": total, "health_filter_applied": health} @@ -430,6 +431,7 @@ def jobs_status_summary(session, *, scope="active"): def dashboard_summary(session, now=None): now = _utc_now(now) + reference = reference_agent_version(session) active = operational_installation_predicate() installation_query = 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(active) health_expression = _health_expression(now) @@ -483,12 +485,12 @@ def dashboard_summary(session, now=None): def installation_candidates(health): query = installation_query.where(health_expression == health).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT) - return [installation_view(i, a, t, s, now=now) for i, a, t, s in session.execute(query).all()] + return [installation_view(i, a, t, s, now=now, reference=reference) for i, a, t, s in session.execute(query).all()] health_candidates = {health: installation_candidates(health) for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN")} - version_filter = or_(AgentSnapshot.version_drift == "OUTDATED", and_(Agent.agent_version.is_not(None), Agent.agent_version != settings.agent_server_version)) + version_filter = Agent.agent_version.in_(outdated_versions(session, reference)) outdated_query = installation_query.where(version_filter).order_by(Installation.id.asc()).limit(ATTENTION_CANDIDATE_LIMIT) - outdated_agents = [installation_view(i, a, t, s, now=now) for i, a, t, s in session.execute(outdated_query).all()] + outdated_agents = [installation_view(i, a, t, s, now=now, reference=reference) for i, a, t, s in session.execute(outdated_query).all()] group_field = func.upper(func.coalesce(BlockedProduct.field, "OTHER")) fiscal_priority = case( @@ -512,13 +514,13 @@ def dashboard_summary(session, now=None): attention = [] for health in ("FAILED", "DEGRADED", "OFFLINE", "UNKNOWN"): rank = {"FAILED": 0, "DEGRADED": 1, "OFFLINE": 2, "UNKNOWN": 3}[health] - attention.extend((_attention_item(item, item["health_reason"], _technical_timestamp(item), rank=rank, now=now), rank, item["agent_id"]) for item in health_candidates[health]) + attention.extend((_attention_item(item, health_reason_label(item["health_reason"]), _technical_timestamp(item), rank=rank, now=now), rank, item["agent_id"]) for item in health_candidates[health]) for client_name, display_name, hostname, agent_id, field, blocker_type, reason, count, products, last_seen, in blocker_groups: item = {"client": client_name, "display_name": display_name, "hostname": hostname, "agent_id": agent_id, "technical_health": "BLOCKED"} link = "/blockers?" + urlencode({"scope": "active", "status": "OPEN", "agent_id": agent_id, "field": field, "blocker_type": blocker_type, "reason_code": reason}) priority = operational_priority({"field": field, "blocker_type": blocker_type, "reason_code": reason}) rank = {"CRITICAL": 3, "HIGH": 4, "NORMAL": 5, "LOW": 6}[priority] - entry = _attention_item(item, f"{field}: {reason}", last_seen, rank=rank, link=link, now=now) + entry = _attention_item(item, f"{field}: {blocker_reason_label(reason)}", last_seen, rank=rank, link=link, now=now) entry.update(group_count=count, products_affected=products, field=field, priority=priority) attention.append((entry, rank, agent_id)) for item in outdated_agents: diff --git a/central/app/installation_identity.py b/central/app/installation_identity.py index c077e78..0f87795 100644 --- a/central/app/installation_identity.py +++ b/central/app/installation_identity.py @@ -94,11 +94,53 @@ def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental= return "ONLINE", "recent heartbeat without critical technical failure" -def compute_version_drift(agent_version: str | None) -> str: +_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)(?:[-_.]?(rc|beta|alpha|b|a)[-_.]?(\d*))?$", re.IGNORECASE) +_PRE_RANK = {"alpha": -3, "a": -3, "beta": -2, "b": -2, "rc": -1} + + +def parse_version(value: str | None): + """Comparable tuple for '1.1.7', '1.0.0rc1', ... (a pre-release sorts below its final); None if unparsable.""" + match = _VERSION_RE.match((value or "").strip()) + if not match: + return None + parts = [int(x) for x in match.group(1).split(".")] + while len(parts) < 4: + parts.append(0) + pre = (_PRE_RANK[match.group(2).lower()], int(match.group(3) or 0)) if match.group(2) else (0, 0) + return (*parts, *pre) + + +def reference_agent_version(session) -> str: + """The version an Agent should be running: the highest PUBLISHED Windows Agent release (REVOKED/DRAFT are + never a target). Falls back to the configured server version when no release is published.""" + from sqlalchemy import select + + from .models import AgentRelease + + best, best_key = None, None + for version in session.scalars(select(AgentRelease.version).where(AgentRelease.status == "PUBLISHED", AgentRelease.platform == "windows")): + key = parse_version(version) + if key is not None and (best_key is None or key > best_key): + best, best_key = version, key + return best or settings.agent_server_version + + +def compute_version_drift(agent_version: str | None, reference: str | None = None) -> str: + """CURRENT when the Agent is at (or ahead of) the reference release, OUTDATED when behind it.""" if not agent_version: return "UNKNOWN" - return "CURRENT" if agent_version == settings.agent_server_version else "OUTDATED" + reference = reference or settings.agent_server_version + have, want = parse_version(agent_version), parse_version(reference) + if have is None or want is None: + return "CURRENT" if agent_version == reference else "OUTDATED" + return "CURRENT" if have >= want else "OUTDATED" -def machine_fingerprint(parts: list[str]) -> str: - return hashlib.sha256("|".join(parts).encode()).hexdigest() +def outdated_versions(session, reference: str) -> list[str]: + """Distinct Agent versions in use that are behind the reference (used to keep SQL filters exact).""" + from sqlalchemy import select + + from .models import Agent + + used = [v for v in session.scalars(select(Agent.agent_version).where(Agent.agent_version.is_not(None)).distinct())] + return [v for v in used if compute_version_drift(v, reference) == "OUTDATED"] diff --git a/central/app/panel_presentation.py b/central/app/panel_presentation.py index ef77de0..6203936 100644 --- a/central/app/panel_presentation.py +++ b/central/app/panel_presentation.py @@ -121,11 +121,31 @@ def job_kind_label(value): def blocker_type_label(value): - return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão"}) + return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão", "OTHER": "Outro"}) + + +_REASON_LABELS = { + "INVALID_NCM": "NCM inválido", "LEGACY_NCM": "NCM legado", "LEGACY_NCM_NO_AUTOMATIC_SUCCESSOR": "NCM legado sem sucessor automático", + "LEGACY_NCM_REVIEW": "NCM legado em revisão", "LEGACY_NCM_REVIEW_REQUIRED": "NCM legado exige revisão", "LEGACY_OR_INVALID_NCM": "NCM legado ou inválido", + "INSUFFICIENT_EVIDENCE": "Evidência insuficiente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", + "MISSING_CONTEXT": "Contexto fiscal ausente", "NO_EVIDENCE": "Sem evidência", "MISSING_PRODUCT_ORIGIN": "Origem do produto ausente", + "MISSING_PROFILE": "Perfil fiscal ausente", "MISSING_ST_DETERMINATION": "Determinação de ST ausente", "NO_IPI_DECISION": "Sem decisão de IPI", + "NO_NUMERIC_VALUE": "Sem valor numérico", "CHANGE_REQUIRED": "Alteração necessária", "SAME_VALUE": "Valor já correto", "AMBIGUOUS": "Ambíguo", +} def blocker_reason_label(value): - return _human_label(value, {"INVALID_NCM": "NCM inválido", "INSUFFICIENT_EVIDENCE": "Evidência insuficiente", "MISSING_CONTEXT": "Contexto fiscal ausente", "NO_EVIDENCE": "Sem evidência"}) + return _human_label(value, _REASON_LABELS) + + +def health_reason_label(value): + """Operator wording for the technical health reason strings produced by compute_agent_health.""" + text = str(value or "") + if text.startswith("last heartbeat exceeds"): + return "Sem heartbeat recente" + return {"no heartbeat received": "Nenhum heartbeat recebido", "persistent technical failure": "Falha técnica persistente", + "recent technical or job failure": "Falha técnica ou de job recente", + "recent heartbeat without critical technical failure": "Operando normalmente"}.get(text, text or "Não informado") def priority_label(value): diff --git a/central/app/web.py b/central/app/web.py index 653596d..33c5eb1 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -14,6 +14,8 @@ from . import agent_distribution, client_sync, config, enrollment, sso from .config import settings from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context from .context_keys import ALLOWED_KEYS, ENUMS +from .installation_identity import reference_agent_version +from .panel_presentation import job_kind_label, job_status_label from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_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 @@ -275,7 +277,7 @@ def router(session_factory): 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) + detail = installation_view(installation, agent, tenant, snapshot, reference=reference_agent_version(session)) 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 @@ -295,7 +297,7 @@ def router(session_factory): next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds) return {"enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60, "last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None, - "last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id} if last_job else None} + "last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id, "kind_label": job_kind_label(last_job.kind), "status_label": job_status_label(last_job.status)} if last_job else None} async def _installation_action(request, agent_id, action): session, auth_data, redirect = page_auth(request) diff --git a/central/static/control-panel.css b/central/static/control-panel.css index 754feb1..ae27ff9 100644 --- a/central/static/control-panel.css +++ b/central/static/control-panel.css @@ -208,3 +208,25 @@ code { overflow-wrap: anywhere; } @media (max-width: 768px) { .cards, .cards.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 520px) { .cards, .cards.compact, .bar-grid { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 520px) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .cards .metric { padding: 12px; } .cards .metric strong { font-size: 1.5rem; } } + +/* ---------- detalhe do Agent: badges de status e hierarquia das ações de processamento ---------- */ +.status-stack { display: flex; flex-wrap: wrap; gap: 16px; } +.status-block { display: grid; gap: 4px; justify-items: start; } +.status-label { font-size: 0.7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--cor-texto-secundario); } +.ops-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.ops-actions form { margin: 0; } +button.caution { background: var(--cor-aviso-fundo); color: var(--cor-aviso); border: 1px solid var(--cor-aviso); } +button.caution:hover { filter: brightness(1.08); } +.ops-actions .botao.secondary, .ops-actions button.secondary { background: transparent; } + +/* ---------- Jobs com falha (Resumo): lista sem tabela, erro resumido, sem scroll horizontal ---------- */ +.failed-jobs { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; } +.failed-job { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 10px 0 10px 12px; border-bottom: 1px solid var(--cor-borda); border-left: 3px solid var(--cor-perigo); margin-bottom: 8px; border-radius: 4px; } +.failed-job:last-child { border-bottom: 0; } +.failed-job-main { min-width: 0; display: grid; gap: 2px; } +.failed-job-main b, .failed-job-main small { overflow-wrap: anywhere; } +.failed-job-kind { font-size: 0.82rem; font-weight: 600; } +.failed-job .job-error-summary { max-width: none; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.failed-job-side { flex: none; display: grid; gap: 4px; justify-items: end; font-size: 0.8rem; color: var(--cor-texto-secundario); white-space: nowrap; } +@media (max-width: 520px) { .failed-job { flex-direction: column; } .failed-job-side { justify-items: start; grid-auto-flow: column; gap: 12px; } } +.ops-actions button:disabled, .ops-actions .primary-action:disabled { opacity: .45; cursor: not-allowed; filter: grayscale(.4); } diff --git a/central/static/tema.js b/central/static/tema.js index eca36fb..77a7f2b 100644 --- a/central/static/tema.js +++ b/central/static/tema.js @@ -6,7 +6,7 @@ function temaAtual() { var salvo = localStorage.getItem("tema"); if (salvo === "claro" || salvo === "escuro") return salvo; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro"; + return "escuro"; // padrão do painel sem preferência salva } botao.addEventListener("click", function () { diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 17ac25d..9a86e0d 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -1,17 +1,17 @@ {% extends "base.html" %} -{% block title %}{{ detail.display_name }} | GTI Fiscal{% endblock %} +{% block title %}{{ detail.display_name or detail.hostname or "Agent" }} | GTI Fiscal{% endblock %} {% block content %} -
{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO
{{ detail.hostname or "hostname indisponível" }}
{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO
{{ detail.hostname or "hostname indisponível" }}
Cobertura ainda não informada: nenhum snapshot recebido deste servidor.
{% elif detail.coverage_invariant_pass %}Cobertura reconciliada para este servidor.
{% else %}Inconsistência de cobertura: {{ detail.coverage_overcount_products }} acima do total{% if detail.coverage_invalid_counts %}; existem contagens negativas{% endif %}.
{% endif %}Controlado pelo Hub — nenhuma configuração local.
Nenhum evento.
{% endfor %}Filtros aplicados no servidor.
Nenhum blocker aberto.
{% endfor %}Nenhum blocker aberto.
{% endfor %}INVENTÁRIO OPERACIONAL
Clientes e servidores ativos. Use Histórico para consultar instalações inativas ou revogadas.
RESOLUÇÃO OPERACIONAL
{{ detail.client or "Cliente não identificado" }} · {{ detail.product_code }}
RESOLUÇÃO OPERACIONAL
{{ detail.client or "Cliente não identificado" }} · {{ detail.product_code }}
{{ error }}
{{ detail.evidence|tojson(indent=2) }}{{ detail.evidence|tojson(indent=2) }}Valor atual por chave persistida para esta empresa.
{% for entry in context_entries %}Nenhum valor definido.
{% endfor %}| Cliente / servidor | Produto | Campo / tipo | Prioridade | Problema | Última ocorrência | Ação |
|---|---|---|---|---|---|---|
| {{ item.client or "Cliente não identificado" }}{{ item.hostname or item.display_name or "Instalação não informada" }} | {{ item.product_code }}{{ item.description or "Sem descrição" }} | {{ item.field or "Outros" }}{{ item.blocker_type_label }} | {{ item.priority_label }} | {{ item.reason_label }}{{ item.age }} | {{ item.last_seen_display }}{{ item.retry_count }} tentativas | Ver detalhe |
| Nenhum blocker corresponde aos filtros. Consultar histórico | ||||||
| {{ item.client or "Cliente não identificado" }}{{ item.hostname or item.display_name or "Instalação não informada" }} | {{ item.product_code }}{{ item.description or "Sem descrição" }} | {{ item.field or "Outros" }}{{ item.blocker_type_label }} | {{ item.priority_label }} | {{ item.reason_label }}{{ item.age }} | {{ item.last_seen_display }}{{ item.retry_count }} tentativas | Ver detalhes |
| Nenhum blocker corresponde aos filtros. Consultar histórico | ||||||
FILA DE AÇÃO
FILA DE AÇÃO
Nenhum item requer atenção imediata.
{% endfor %} @@ -41,12 +41,12 @@{{ summary.failed_jobs }} jobs aguardam investigação.
-| Cliente / servidor | Tipo / erro | Falhou | Ação |
|---|---|---|---|
| {{ job.client or "Cliente não identificado" }}{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }} | {{ job.kind_label }}{{ job.error or "Sem detalhe de erro" }} | {{ job.finished_display }}{{ job.finished_age }} | Ver job #{{ job.job_id }} |
Nenhum job com falha.
{% endif %} +Nenhum job com falha.
{% endif %}Nenhum Agent desatualizado.
{% endfor %}Filtros ativos: {% if scope_filter=="all" %}Histórico e inativos{% endif %}{% if status_filter %}Status: {{ {"PENDING":"Pendente","RUNNING":"Executando","COMPLETED":"Concluído","FAILED":"Falhou"}[status_filter] }}{% endif %}{% if job_id_filter %}Job #{{ job_id_filter }}{% endif %}
{% endif %} {% if job_id_filter and result["items"] %}{% set selected = result["items"][0] %}{{ selected.client or "Cliente não identificado" }} · {{ selected.display_name or "Instalação" }} · {{ selected.hostname or "Servidor não informado" }}
{{ selected.kind_label }} {{ selected.status_label }}
Erro completo
{{ selected.error or "Sem erro registrado" }}Tipo técnico: {{ selected.kind }} · Status técnico: {{ selected.status }}| Cliente / servidor | Tipo / job | Status | Criado | Finalizado / duração | Erro / ação |
|---|---|---|---|---|---|
| {{ item.client or "Cliente não identificado" }}{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }} | {{ item.kind_label }}Job #{{ item.job_id }} | {{ item.status_label }} | {{ item.created_display }} | {{ item.finished_display }}{{ item.duration }} | {{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}Ver job |
| Nenhum job encontrado. Consultar histórico | |||||
| {{ item.client or "Cliente não identificado" }}{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }} | {{ item.kind_label }}Job #{{ item.job_id }} | {{ item.status_label }} | {{ item.created_display }} | {{ item.finished_display }}{{ item.duration }} | {{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}Ver detalhes |
| Nenhum job encontrado. Consultar histórico | |||||