test: complete control panel fixture UX coverage
This commit is contained in:
@@ -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)
|
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")
|
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()
|
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
|
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)))
|
failed = state in {"DEGRADED", "FAILED"}
|
||||||
created["events"] += 1
|
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(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))
|
session.add(job); session.flush()
|
||||||
created["jobs"] += 1
|
created["jobs"] += 1
|
||||||
|
|
||||||
|
fixture_blocker = None
|
||||||
if index % 2 == 0:
|
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
|
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()
|
session.commit()
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|||||||
@@ -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.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():
|
def test_fixture_creates_one_hundred_varied_installations_and_events():
|
||||||
session_factory = setup_db()
|
session_factory = setup_db()
|
||||||
with session_factory() as session:
|
with session_factory() as session:
|
||||||
result = generate_fixture(session, agent_count=100, seed=11)
|
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(Installation).count() == 100
|
||||||
assert session.query(AgentEvent).count() == 500
|
assert session.query(AgentEvent).count() == 500
|
||||||
assert session.query(Job).count() == 100
|
assert session.query(Job).count() == 100
|
||||||
assert {row.agent_health for row in session.query(AgentSnapshot).all()} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN"}
|
snapshots = session.query(AgentSnapshot).all()
|
||||||
assert {row.version_drift for row in session.query(AgentSnapshot).all()} == {"CURRENT", "OUTDATED", "UNKNOWN"}
|
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())
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
`/login`. Primary rows show client, display name and hostname; UUIDs remain in
|
||||||
technical detail.
|
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
|
## Safe Actions
|
||||||
|
|
||||||
Human actions require a session, CSRF, explicit confirmation and an
|
Human actions require a session and CSRF; mutating actions are audited in
|
||||||
`audit_events` row. The panel can create reprocess jobs and resolve blockers
|
`audit_events`. The panel can create reprocess jobs and save blocker context
|
||||||
only with explicit context. It cannot enable Firebird/AI writes, alter
|
only through their validated workflows. It cannot enable Firebird/AI writes,
|
||||||
credentials/path/CRT, execute SQL or shell commands, restart Windows services,
|
alter credentials/path/CRT, execute SQL or shell commands, restart Windows
|
||||||
or update Agents automatically.
|
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
|
## Fixture
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user