feat: UI polish - dark default, version drift vs default release, humanized enums, failed-jobs layout, processing button hierarchy, PT-BR titles/CTAs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user