test: complete control panel fixture UX coverage

This commit is contained in:
gti
2026-09-22 20:49:04 -03:00
parent 0eeb7d159c
commit 4ba0fa8a53
4 changed files with 214 additions and 16 deletions
@@ -23,16 +23,26 @@ def generate_fixture(session, agent_count=100, seed=7):
last_seen = None if state == "UNKNOWN" else now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000)
installation = Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")), display_name=f"{tenant.name} - Servidor Principal", customer_code=f"F{index:03d}", hostname=f"srv-{index:03d}", company_name=f"Empresa {index:03d}", company_uf="BA", company_crt="1", last_seen_at=last_seen, last_status="IDLE")
session.add(installation); session.flush()
snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=index % 9, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED")
snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=1 if index % 2 == 0 else 0, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED")
session.add(snapshot); created["agents"] += 1
for event_index in range(5):
session.add(AgentEvent(tenant_id=tenant.id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if state == "FAILED" and event_index == 0 else "INFO", event_type="JOB_FAILED" if state in {"DEGRADED", "FAILED"} and event_index == 0 else "HEARTBEAT", message="Fixture operational event", structured_context={"fixture": True}, occurred_at=now - timedelta(minutes=event_index)))
created["events"] += 1
session.add(Job(agent_id=agent.id, kind="REPROCESS_ALL", status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED", payload={}, result_summary={"fixture": True}, error="fixture technical failure" if state in {"DEGRADED", "FAILED"} else None))
failed = state in {"DEGRADED", "FAILED"}
job = Job(agent_id=agent.id, kind="REPROCESS_ALL", status="FAILED" if failed else "COMPLETED", payload={}, result_summary={"fixture": True}, error="fixture technical failure" if failed else None, started_at=now - timedelta(minutes=6) if failed else None, finished_at=now - timedelta(minutes=5) if failed else None)
session.add(job); session.flush()
created["jobs"] += 1
fixture_blocker = None
if index % 2 == 0:
session.add(BlockedProduct(tenant_id=tenant.id, installation_id=installation.id, company=tenant.name, product_code=f"P{index:06d}", description="Fixture product", blocker_type="CFOP", field="CFOP", reason_code="INSUFFICIENT_EVIDENCE", evidence_summary={"fixture": True}, input_fingerprint=f"{index:064d}", blocker_fingerprint=f"{index+1000:064d}"))
fixture_blocker = BlockedProduct(tenant_id=tenant.id, installation_id=installation.id, company=tenant.name, product_code=f"P{index:06d}", description="Fixture product", blocker_type="CFOP", field="CFOP", reason_code="INSUFFICIENT_EVIDENCE", evidence_summary={"fixture": True}, input_fingerprint=f"{index:064d}", blocker_fingerprint=f"{index+1000:064d}")
session.add(fixture_blocker); session.flush()
created["blockers"] += 1
if index == 0:
session.add(BlockedProduct(tenant_id=tenant.id, installation_id=installation.id, company=tenant.name, product_code=fixture_blocker.product_code, description="Fixture product", blocker_type="TAX_RULE_MISSING", field="ST", reason_code="MISSING_REFERENCE", evidence_summary={"fixture": True}, input_fingerprint=f"{index+2000:064d}", blocker_fingerprint=f"{index+3000:064d}"))
created["blockers"] += 1
for event_index in range(5):
linked = event_index == 0
session.add(AgentEvent(tenant_id=tenant.id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if failed and linked else "INFO", event_type="JOB_FAILED" if failed and linked else "HEARTBEAT", message="Fixture job failed" if failed and linked else "Fixture operational event", structured_context={"fixture": True}, job_id=job.id if linked else None, product_code=fixture_blocker.product_code if linked and fixture_blocker else None, blocker_id=fixture_blocker.id if linked and fixture_blocker else None, occurred_at=now - timedelta(minutes=event_index)))
created["events"] += 1
session.commit()
return created
+33 -5
View File
@@ -1,15 +1,43 @@
from central.tests.test_control_plane import setup_db
import uuid
from central.scripts.generate_control_panel_fixture import generate_fixture
from central.app.models import AgentEvent, AgentSnapshot, Installation, Job
from central.app.models import AgentEvent, AgentSnapshot, BlockedProduct, Installation, Job
from central.tests.test_control_plane import setup_db
def test_fixture_creates_one_hundred_varied_installations_and_events():
session_factory = setup_db()
with session_factory() as session:
result = generate_fixture(session, agent_count=100, seed=11)
assert result == {"agents": 100, "events": 500, "blockers": 50, "jobs": 100}
assert result == {"agents": 100, "events": 500, "blockers": 51, "jobs": 100}
assert session.query(Installation).count() == 100
assert session.query(AgentEvent).count() == 500
assert session.query(Job).count() == 100
assert {row.agent_health for row in session.query(AgentSnapshot).all()} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN"}
assert {row.version_drift for row in session.query(AgentSnapshot).all()} == {"CURRENT", "OUTDATED", "UNKNOWN"}
snapshots = session.query(AgentSnapshot).all()
assert {row.agent_health for row in snapshots} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN"}
assert {row.version_drift for row in snapshots} == {"CURRENT", "OUTDATED", "UNKNOWN"}
assert {row.installation_id for row in session.query(Installation).all()} == {str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")) for index in range(100)}
blockers = session.query(BlockedProduct).all()
assert len({row.blocker_fingerprint for row in blockers}) == len(blockers)
for snapshot in snapshots:
product_codes = {row.product_code for row in blockers if row.installation_id == snapshot.installation_id and row.resolved_at is None}
assert snapshot.safe_blocked == len(product_codes)
repeated_product = next(row for row in blockers if row.product_code == "P000000")
same_product_blockers = [row for row in blockers if row.tenant_id == repeated_product.tenant_id and row.installation_id == repeated_product.installation_id and row.product_code == repeated_product.product_code]
assert len(same_product_blockers) >= 2
assert all(row.resolved_at is None for row in same_product_blockers)
assert all(row.tenant_id == session.query(Installation).filter_by(id=row.installation_id).one().tenant_id for row in blockers)
failed_jobs = session.query(Job).filter_by(status="FAILED").all()
assert failed_jobs and all(job.started_at is not None and job.finished_at is not None and job.finished_at >= job.started_at for job in failed_jobs)
linked_events = session.query(AgentEvent).filter(AgentEvent.job_id.is_not(None), AgentEvent.blocker_id.is_not(None)).all()
assert linked_events
for event in linked_events:
job = session.get(Job, event.job_id)
blocker = session.get(BlockedProduct, event.blocker_id)
installation = session.query(Installation).filter_by(id=blocker.installation_id).one()
assert event.installation_id == installation.id == session.query(Installation).filter_by(agent_id=job.agent_id).one().id
assert event.tenant_id == blocker.tenant_id == installation.tenant_id
assert event.product_code == blocker.product_code
assert all("password" not in event.message.lower() and "token" not in event.message.lower() for event in session.query(AgentEvent).all())
+150
View File
@@ -0,0 +1,150 @@
# Control Panel Operational UX V1
This document defines the operator-facing contracts for the existing
server-rendered GTI Fiscal control panel. It complements
[`CONTROL_PANEL_V1.md`](CONTROL_PANEL_V1.md), which documents architecture,
identity, authentication, APIs, retention, and the explicit fixture command.
The UX work does not change fiscal rules, the Hub/Agent protocol, Firebird
access, AI write controls, or deployment.
## Dashboard health and fiscal metrics
Technical health is summarized by exactly five states: `ONLINE`, `DEGRADED`,
`OFFLINE`, `FAILED`, and `UNKNOWN`. The dashboard total is reconciled from those
state counts and must always satisfy:
```text
agents_total = online + degraded + offline + failed + unknown
```
Technical health describes Agent/installation availability and processing
failures. Fiscal status and fiscal completion are separate: safe-blocked fiscal
work does not make an otherwise healthy Agent technically degraded or failed.
Agent views show product total, fiscally complete count, safe-blocked count,
failed count, and fiscal completion percentage. The percentage is
`fiscally_complete / total_products * 100`, rounded to one decimal place and
bounded to 0–100; when the total is zero, the stored coverage percentage is
used. The fiscal status badge is `BLOCKED` when safe-blocked work exists,
`COMPLETE` when all products are complete, and otherwise `INCOMPLETE`.
Two blocker counts must remain distinct:
- **Safe-blocked products**: distinct open `(tenant_id, product_code)` pairs.
- **Open blockers**: open blocker rows; one product can have multiple rows for
different fields or evidence.
Dashboard attention is limited to ten items. Items are ordered by technical
health (`FAILED`, `DEGRADED`, `OFFLINE`, `UNKNOWN`), required-field blockers,
then outdated Agent versions; ties are stable. Safe-blocked products and
outdated versions do not imply technical failure. Failed jobs and outdated
Agents are also shown in compact sections, each with an explicit empty state
when there is no data. Dashboard health and fiscal summary cards remain visible
when lists are empty.
## Identity and time presentation
Agents, jobs, and blockers lead with human identity: client, installation
display name, and hostname where available. Technical numeric IDs and UUIDs
remain secondary details. Agent tables distinguish technical health from
fiscal status and provide a worst-fiscal-coverage sort. Job rows identify the
client and installation and show job ID, kind, status, timestamps, duration,
and error. Blocker rows identify the client/installation, product, field,
blocker type, operational priority, age, and detail link.
Database and API timestamps remain UTC. UI timestamps render in
`UI_TIMEZONE`, defaulting to `America/Bahia`, as `DD/MM/YYYY HH:MM` with a
relative age alongside them; a full technical timestamp is available in the
element title where applicable. Missing timestamps render as `-`. Job duration
uses start/finish timestamps (`em andamento` if not finished, `-` if not
started); it does not replace UTC persistence or API values.
## Blocker list, summary, and operational priority
The blocker page applies filters on the server: client, installation, field,
blocker type, status, age, and product/search. The filtered summary reports
open blocker rows, distinct affected `(tenant_id, product_code)` products,
distinct affected clients, and row breakdown by `CFOP`, `ST`, `ICMS`, `CSOSN`,
`NCM`, and `OTHER`. Filtered rows and their summary use the same filter set.
Priority is deterministic and operational only; it does not represent tax
severity, fiscal risk, legal consequence, or a tax recommendation. The current
tiers are:
- `CRITICAL`: known technical/connection/job-failure blockers or technical and
timeout reasons that prevent processing.
- `HIGH`: missing-evidence blocker types that require operator attention.
- `NORMAL`: recognized fiscal fields (`CFOP`, `ST`, `ICMS`, `CSOSN`, `NCM`) or
insufficient/no-evidence reasons without a higher operational tier.
- `LOW`: other optional or reference-enrichment cases.
The tier changes ordering/triage only. It must not be described as invented tax
severity.
## Details, events, and safe operations
Agent details present client, display name, and hostname before technical IDs,
separate technical/fiscal/version badges, and show human-formatted last-seen
time plus company context. Fiscal summary links to filtered blockers. Event
history supports server-side severity, type, date, job, and product filters
with pagination; event rows expose related job/product links when present.
Blocker details show evidence, first/last seen, retry count, related
events/jobs, and resolution audit history when available.
Resolving a blocker means saving validated operator context and queueing
reprocessing for that product; it does not directly write fiscal data or
immediately mark historical blocker evidence resolved. The workflow requires a
human-authenticated session and valid CSRF token. Accepted scopes are
`PRODUCT`, `GROUP`, and `COMPANY`; the scope/key/value are validated using the
existing context-key rules, including enum restrictions, and the required
reason/note is length-limited. `PRODUCT` scope must use the blocker product
code as its scope key. Arbitrary fields or keys are rejected.
The transaction creates or updates the validated context entry, creates a
`REPROCESS_PRODUCTS` job whose product list contains only the blocker product,
and records an audit event. The audit context includes operator/admin ID,
tenant/client, company key, installation identifiers, product code, blocker ID,
context key/scope/scope key/value, reason, before/after values, reprocess job
ID, and request IP when available. Validation or persistence failure rolls back
the operation. The action cannot write to Firebird, execute SQL/shell, or
enable AI writes.
Installation-wide reprocessing has its own confirmation page at
`/agents/{agent_id}/reprocess-confirmation`; only an explicit confirmed POST
with CSRF creates or reuses the pending full-reprocess job and records an audit
event. The confirmation page is part of the operator workflow, not a browser
`window.confirm` dialog.
## Authentication policy
Production administrator creation always requires the strong password policy.
Short/weak local development passwords are accepted only when
`ALLOW_WEAK_DEV_PASSWORD=1` and `APP_ENV` is not `production`. The flag never
weakens production policy. Existing local administrator credentials are not
changed by the control panel.
## Limits, responsive behavior, and boundaries
Dashboard metrics and lists use snapshots and bounded queries; the dashboard
does not scan product tables. List pagination is capped at 100 rows per page;
the UI requests 50 rows for Agents, Jobs, and Blockers, and 25 event rows per
Agent detail page. Dashboard attention is capped at ten; recent failed-job rows
are capped at ten. Blocker detail loads at most 100 related events/jobs/audits,
and displays a compact subset of event/job rows. Filters and pagination are
server-side.
The interface remains server-rendered with project-local CSS and minimal
progressive enhancement. Layouts adapt to desktop, tablet, and narrow screens;
wide data tables remain horizontally scrollable, focus states remain visible,
and health/status meaning is not communicated by color alone. Empty states
explain when there are no failed jobs, outdated Agents, blockers, or related
events/jobs.
The explicit non-production fixture is generated with:
```text
python -m central.scripts.generate_control_panel_fixture --agents 100 --confirm-non-production
```
The command preserves its non-production confirmation gate and is never run at
startup or migration time. This UX introduces no AI writes, Firebird writes,
production database changes, or deployment actions.
+15 -5
View File
@@ -84,13 +84,23 @@ The UI pages are `/`, `/agents`, `/agents/{agent_id}`, `/blockers`, `/jobs` and
`/login`. Primary rows show client, display name and hostname; UUIDs remain in
technical detail.
For dashboard semantics, operator workflows, timestamp presentation, bounded
filters, and responsive behavior, see
[`CONTROL_PANEL_OPERATIONAL_UX_V1.md`](CONTROL_PANEL_OPERATIONAL_UX_V1.md).
## Safe Actions
Human actions require a session, CSRF, explicit confirmation and an
`audit_events` row. The panel can create reprocess jobs and resolve blockers
only with explicit context. It cannot enable Firebird/AI writes, alter
credentials/path/CRT, execute SQL or shell commands, restart Windows services,
or update Agents automatically.
Human actions require a session and CSRF; mutating actions are audited in
`audit_events`. The panel can create reprocess jobs and save blocker context
only through their validated workflows. It cannot enable Firebird/AI writes,
alter credentials/path/CRT, execute SQL or shell commands, restart Windows
services, or update Agents automatically.
Reprocessing an installation presents a separate confirmation page before the
POST. Saving blocker context is a separate authenticated, CSRF-protected
workflow that validates the context and queues reprocessing for only that
blocker's product. See the operational UX contract for validation and audit
details.
## Fixture