feat(sync): /api/sync/health endpoint + dashboard health pill + MALFORMED UI

Backend:
- GET /api/sync/health returns {last_sync_at, last_sync_status,
  last_halt_reason, recent_phase_failures, escalation_phase, is_healthy}.
  healthy when last run was completed (or none yet), no phase has
  tripped the 3-in-a-row escalation, and recent failures <= 1.
- Dashboard + run-level endpoints include `malformed` count so the
  Defecte pill can render.

Frontend:
- Health pill in .sync-card-controls with three states — healthy
  (success green, check icon), warning (amber, triangle), escalated
  (error red, x-octagon + glow). Tooltip exposes the halt reason and
  the top phases with recent failures.
- Status-dot + badge add MALFORMED treatment via --compare orange,
  distinct from ERROR red. DESIGN.md notes the diagnostic rationale
  (ERROR = runtime, MALFORMED = payload source issue).
- Defecte filter pill on dashboard + logs pages. Mobile segmented
  control includes Defecte count. Counts wired to the malformed key.
- startSync() shows a native confirm modal when state is
  halted_escalation — operator override still possible, not silenced.
- ORDER_STATUS.MALFORMED mirror added to shared.js.
- Cache-bust: style.css v46, shared.js v47, dashboard.js v52,
  logs.js v16.

5 endpoint tests cover empty state, completed, failed, escalated,
single-failure warning. Full CI: 257 unit + 33 e2e green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-22 09:14:46 +00:00
parent 41b142effb
commit bb6f3a3b87
11 changed files with 290 additions and 5 deletions

View File

@@ -160,6 +160,52 @@ async def sync_status():
return result
@router.get("/api/sync/health")
async def sync_health():
"""Aggregated sync health snapshot used by the dashboard pill.
Fields:
last_sync_at ISO timestamp of most recent run start (or null).
last_sync_status completed | failed | running | halted_escalation | null.
last_halt_reason error_message from that run (only populated on
failed / halted_escalation).
recent_phase_failures {phase: count} across the last 3 runs.
escalation_phase the phase that tripped the 3-in-a-row halt, or null.
is_healthy completed last + <=1 recent phase failure.
"""
db = await sqlite_service.get_sqlite()
try:
cursor = await db.execute(
"SELECT run_id, started_at, status, error_message "
"FROM sync_runs ORDER BY started_at DESC LIMIT 1"
)
last_row = await cursor.fetchone()
finally:
await db.close()
last = dict(last_row) if last_row else {}
last_status = last.get("status")
halt_reason = last.get("error_message") if last_status in ("failed", "halted_escalation") else None
counts = await sqlite_service.get_recent_phase_failures(limit=3)
escalation_phase = next((p for p, c in counts.items() if c >= 3), None)
is_healthy = (
last_status in (None, "completed")
and escalation_phase is None
and sum(counts.values()) <= 1
)
return {
"last_sync_at": last.get("started_at"),
"last_sync_status": last_status,
"last_halt_reason": halt_reason,
"recent_phase_failures": counts,
"escalation_phase": escalation_phase,
"is_healthy": is_healthy,
}
@router.get("/api/sync/history")
async def sync_history(page: int = 1, per_page: int = 20):
"""Get sync run history."""

View File

@@ -1000,6 +1000,7 @@ async def get_run_orders_filtered(run_id: str, status_filter: str = "all",
"error": status_counts.get(OrderStatus.ERROR.value, 0),
"already_imported": status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
"total": sum(status_counts.values())
}
}
@@ -1135,6 +1136,7 @@ async def get_orders(page: int = 1, per_page: int = 50,
"skipped": status_counts.get(OrderStatus.SKIPPED.value, 0),
"error": status_counts.get(OrderStatus.ERROR.value, 0),
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
"total": sum(status_counts.values()),
"uninvoiced_sqlite": uninvoiced_sqlite,
"uninvoiced_old": uninvoiced_old,

View File

@@ -495,6 +495,7 @@ input[type="checkbox"] {
.dot-yellow { background: var(--warning); box-shadow: 0 0 6px 2px rgba(202,138,4,0.3); }
.dot-red { background: var(--error); box-shadow: 0 0 8px 2px rgba(220,38,38,0.35); }
.dot-gray { background: var(--cancelled); }
.dot-orange { background: var(--compare); box-shadow: 0 0 8px 2px rgba(234,88,12,0.35); }
.dot-blue { background: var(--info); }
/* ── Flat row (mobile + desktop) ────────────────── */
@@ -805,6 +806,45 @@ tr.mapping-deleted td {
.sync-status-dot.running { background: var(--info); animation: pulse-dot 2s ease-in-out infinite; }
.sync-status-dot.completed { background: var(--success); }
.sync-status-dot.failed { background: var(--error); }
.sync-status-dot.malformed { background: var(--compare); box-shadow: 0 0 8px 2px rgba(234,88,12,0.35); }
/* ── Sync health pill (dashboard only) ────────────── */
.health-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
min-height: 32px;
border-radius: 999px;
font-size: 0.8125rem;
line-height: 1;
font-weight: 500;
border: 1px solid transparent;
cursor: default;
user-select: none;
transition: background 120ms ease;
}
.health-pill i { font-size: 0.9375rem; line-height: 1; }
.health-pill.healthy {
background: var(--success-light);
color: var(--success-text);
border-color: var(--success);
}
.health-pill.warning {
background: var(--warning-light);
color: var(--warning-text);
border-color: var(--warning);
}
.health-pill.escalated {
background: var(--error-light);
color: var(--error-text);
border-color: var(--error);
box-shadow: 0 0 8px 2px rgba(220,38,38,0.35);
}
/* Ensure adequate touch target on mobile */
@media (max-width: 600px) {
.health-pill { min-height: 44px; padding: 0.5rem 0.75rem; }
}
/* ── Custom period range inputs ──────────────────── */
.period-custom-range {

View File

@@ -162,9 +162,76 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// ── Sync Health pill ─────────────────────────────
let _lastHealth = null;
async function pollSyncHealth() {
try {
const data = await fetchJSON('/api/sync/health');
_lastHealth = data;
renderHealthPill(data);
} catch (e) { /* fail-soft: keep last state */ }
}
function renderHealthPill(h) {
const pill = document.getElementById('syncHealthPill');
if (!pill) return;
const icon = pill.querySelector('i');
const label = pill.querySelector('.health-pill-label');
let state = 'healthy', iconCls = 'bi-check-circle-fill', text = 'Sanatos', tooltip;
const recent = h.recent_phase_failures || {};
const recentCount = Object.values(recent).reduce((a, b) => a + (b || 0), 0);
if (h.escalation_phase || h.last_sync_status === 'halted_escalation') {
state = 'escalated';
iconCls = 'bi-x-octagon-fill';
text = 'Blocat';
tooltip = `Blocat — faza "${h.escalation_phase || '?'}" a esuat 3 sync-uri consecutive.\n`
+ `Ultima eroare: ${h.last_halt_reason || '—'}\n`
+ `Click Start Sync pentru override manual.`;
} else if (h.last_sync_status === 'failed' || recentCount > 0) {
state = 'warning';
iconCls = 'bi-exclamation-triangle-fill';
text = 'Atentie';
const topPhases = Object.entries(recent).slice(0, 3)
.map(([p, c]) => `${p} (${c} of last 3)`).join(', ');
tooltip = `Atentie — ${topPhases || 'sync anterior esuat'}`
+ (h.last_halt_reason ? `\nLast error: ${h.last_halt_reason}` : '');
} else {
const lastAt = h.last_sync_at ? h.last_sync_at.replace('T', ' ').slice(5, 16) : 'nicio rulare';
tooltip = `Sanatos — ultimul sync: ${lastAt}`;
}
pill.className = 'health-pill ' + state;
pill.setAttribute('aria-label', `Sync: ${text}`);
pill.title = tooltip;
if (icon) icon.className = 'bi ' + iconCls;
if (label) label.textContent = text;
}
function startHealthPolling() {
pollSyncHealth();
setInterval(pollSyncHealth, 10000);
}
document.addEventListener('DOMContentLoaded', startHealthPolling);
// ── Sync Controls ─────────────────────────────────
async function startSync() {
// Escalation override — confirm before overriding the auto-halt
if (_lastHealth && (_lastHealth.escalation_phase
|| _lastHealth.last_sync_status === 'halted_escalation')) {
const phase = _lastHealth.escalation_phase || '?';
const reason = _lastHealth.last_halt_reason || '(unknown)';
const msg = `⚠ Sync blocat automat\n\n`
+ `Faza "${phase}" a esuat in ultimele 3 sync-uri consecutive.\n`
+ `Ultima eroare: ${reason}\n\n`
+ `Repornesti oricum?`;
if (!confirm(msg)) return;
}
try {
const res = await fetch('/api/sync/start', { method: 'POST' });
const data = await res.json();
@@ -174,6 +241,7 @@ async function startSync() {
}
// Polling will detect the running state — just speed it up immediately
pollSyncStatus();
pollSyncHealth();
} catch (err) {
alert('Eroare: ' + err.message);
}
@@ -329,6 +397,7 @@ async function loadDashOrders() {
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 0;
if (el('cntMal')) el('cntMal').textContent = c.malformed || 0;
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
// Attention card
@@ -415,6 +484,7 @@ async function loadDashOrders() {
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
{ label: 'Nefact.', count: c.nefacturate || c.uninvoiced || 0, value: 'UNINVOICED', active: activeStatus === 'UNINVOICED', colorClass: 'fc-red' },
{ label: 'Anulate', count: c.cancelled || 0, value: ORDER_STATUS.CANCELLED, active: activeStatus === ORDER_STATUS.CANCELLED, colorClass: 'fc-dark' },
{ label: 'Def.', count: c.malformed || 0, value: ORDER_STATUS.MALFORMED, active: activeStatus === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' },
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
], (val) => {
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));

View File

@@ -137,6 +137,8 @@ async function loadRunOrders(runId, statusFilter, page) {
document.getElementById('countError').textContent = counts.error || 0;
const alreadyEl = document.getElementById('countAlreadyImported');
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
const malEl = document.getElementById('countMalformed');
if (malEl) malEl.textContent = counts.malformed || 0;
const tbody = document.getElementById('runOrdersBody');
const orders = data.orders || [];
@@ -238,7 +240,8 @@ async function loadRunOrders(runId, statusFilter, page) {
{ label: 'Imp.', count: counts.imported || 0, value: ORDER_STATUS.IMPORTED, active: currentFilter === ORDER_STATUS.IMPORTED, colorClass: 'fc-green' },
{ label: 'Deja', count: counts.already_imported || 0, value: ORDER_STATUS.ALREADY_IMPORTED, active: currentFilter === ORDER_STATUS.ALREADY_IMPORTED, colorClass: 'fc-blue' },
{ label: 'Omise', count: counts.skipped || 0, value: ORDER_STATUS.SKIPPED, active: currentFilter === ORDER_STATUS.SKIPPED, colorClass: 'fc-yellow' },
{ label: 'Erori', count: counts.error || 0, value: ORDER_STATUS.ERROR, active: currentFilter === ORDER_STATUS.ERROR, colorClass: 'fc-red' }
{ label: 'Erori', count: counts.error || 0, value: ORDER_STATUS.ERROR, active: currentFilter === ORDER_STATUS.ERROR, colorClass: 'fc-red' },
{ label: 'Defecte', count: counts.malformed || 0, value: ORDER_STATUS.MALFORMED, active: currentFilter === ORDER_STATUS.MALFORMED, colorClass: 'fc-orange' }
], (val) => filterOrders(val));
// Orders pagination

View File

@@ -19,6 +19,7 @@ const ORDER_STATUS = Object.freeze({
ERROR: 'ERROR',
CANCELLED: 'CANCELLED',
DELETED_IN_ROA: 'DELETED_IN_ROA',
MALFORMED: 'MALFORMED',
});
// ── HTML escaping ─────────────────────────────────
@@ -519,6 +520,7 @@ function orderStatusBadge(status) {
case ORDER_STATUS.ERROR: return '<span class="badge bg-danger">Eroare</span>';
case ORDER_STATUS.CANCELLED: return '<span class="badge bg-secondary">Anulat</span>';
case ORDER_STATUS.DELETED_IN_ROA: return '<span class="badge bg-dark">Sters din ROA</span>';
case ORDER_STATUS.MALFORMED: return '<span class="badge" style="background:var(--compare-light);color:var(--compare-text);border:1px solid var(--compare)">Defect</span>';
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
}
}
@@ -1037,6 +1039,8 @@ function statusDot(status) {
case ORDER_STATUS.ERROR:
case 'FAILED':
return '<span class="dot dot-red"></span>';
case ORDER_STATUS.MALFORMED:
return '<span class="dot dot-orange" title="Date defecte — escalat la GoMag"></span>';
case ORDER_STATUS.CANCELLED:
case ORDER_STATUS.DELETED_IN_ROA:
return '<span class="dot dot-gray"></span>';

View File

@@ -19,7 +19,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
{% set rp = request.scope.get('root_path', '') %}
<link href="{{ rp }}/static/css/style.css?v=45" rel="stylesheet">
<link href="{{ rp }}/static/css/style.css?v=46" rel="stylesheet">
</head>
<body>
<!-- Top Navbar (hidden on mobile via CSS) -->
@@ -169,7 +169,7 @@
<script>window.ROOT_PATH = "{{ rp }}";</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ rp }}/static/js/shared.js?v=46"></script>
<script src="{{ rp }}/static/js/shared.js?v=47"></script>
<script>
// Dark mode toggle
function toggleDarkMode() {

View File

@@ -14,6 +14,11 @@
<div class="sync-card-controls">
<span id="syncStatusDot" class="sync-status-dot idle"></span>
<span id="syncStatusText" class="text-secondary">Inactiv</span>
<span id="syncHealthPill" class="health-pill healthy" role="status"
aria-label="Sync sanatos" title="Verificare stare sync">
<i class="bi bi-check-circle-fill" aria-hidden="true"></i>
<span class="health-pill-label">Sanatos</span>
</span>
<div class="d-flex align-items-center gap-2">
<label class="d-flex align-items-center gap-1 text-muted">
Auto:
@@ -76,6 +81,7 @@
<button class="filter-pill d-none d-md-inline-flex" data-status="INVOICED">Facturate <span class="filter-count fc-green" id="cntFact">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="UNINVOICED">Nefacturate <span class="filter-count fc-red" id="cntNef">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.CANCELLED.value }}">Anulate <span class="filter-count fc-dark" id="cntCanc">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="{{ OrderStatus.MALFORMED.value }}">Defecte <span class="filter-count fc-orange" id="cntMal">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-status="DIFFS">Diferente <span class="filter-count fc-orange" id="cntDiff">0</span></button>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline-flex" id="btnRefreshInvoices" onclick="refreshInvoices()" title="Actualizeaza status facturi din Oracle">&#8635;</button>
</div>
@@ -115,5 +121,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=51"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=52"></script>
{% endblock %}

View File

@@ -63,6 +63,7 @@
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.ALREADY_IMPORTED.value }}">Deja imp. <span class="filter-count fc-blue" id="countAlreadyImported">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.SKIPPED.value }}">Omise <span class="filter-count fc-yellow" id="countSkipped">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.ERROR.value }}">Erori <span class="filter-count fc-red" id="countError">0</span></button>
<button class="filter-pill d-none d-md-inline-flex" data-log-status="{{ OrderStatus.MALFORMED.value }}">Defecte <span class="filter-count fc-orange" id="countMalformed">0</span></button>
</div>
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
@@ -109,5 +110,5 @@
{% endblock %}
{% block scripts %}
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=15"></script>
<script src="{{ request.scope.get('root_path', '') }}/static/js/logs.js?v=16"></script>
{% endblock %}

View File

@@ -0,0 +1,110 @@
"""Tests for GET /api/sync/health."""
import os
import sys
import tempfile
import pytest
pytestmark = pytest.mark.unit
_tmpdir = tempfile.mkdtemp()
os.environ.setdefault("FORCE_THIN_MODE", "true")
os.environ.setdefault("SQLITE_DB_PATH", os.path.join(_tmpdir, "test_health.db"))
os.environ.setdefault("ORACLE_DSN", "dummy")
os.environ.setdefault("ORACLE_USER", "dummy")
os.environ.setdefault("ORACLE_PASSWORD", "dummy")
os.environ.setdefault("JSON_OUTPUT_DIR", _tmpdir)
_api_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _api_dir not in sys.path:
sys.path.insert(0, _api_dir)
from fastapi.testclient import TestClient
from app import database
from app.services import sqlite_service
from app.main import app
client = TestClient(app)
@pytest.fixture(autouse=True)
async def _reset():
database.init_sqlite()
db = await sqlite_service.get_sqlite()
try:
await db.execute("DELETE FROM sync_phase_failures")
await db.execute("DELETE FROM sync_runs")
await db.commit()
finally:
await db.close()
yield
async def _make_run(run_id: str, status: str = "completed", offset: int = 0,
error_message: str | None = None):
db = await sqlite_service.get_sqlite()
try:
await db.execute(
"""INSERT INTO sync_runs (run_id, started_at, status, error_message)
VALUES (?, datetime('now', ?), ?, ?)""",
(run_id, f"{offset} seconds", status, error_message),
)
await db.commit()
finally:
await db.close()
async def test_health_empty_state():
r = client.get("/api/sync/health")
assert r.status_code == 200
data = r.json()
assert data["last_sync_at"] is None
assert data["last_sync_status"] is None
assert data["recent_phase_failures"] == {}
assert data["escalation_phase"] is None
assert data["is_healthy"] is True
async def test_health_completed_is_healthy():
await _make_run("ok-1", status="completed")
r = client.get("/api/sync/health")
data = r.json()
assert data["last_sync_status"] == "completed"
assert data["is_healthy"] is True
async def test_health_reports_last_failure():
await _make_run("fail-1", status="failed", error_message="boom")
r = client.get("/api/sync/health")
data = r.json()
assert data["last_sync_status"] == "failed"
assert data["last_halt_reason"] == "boom"
assert data["is_healthy"] is False
async def test_health_detects_escalation():
# 3 consecutive runs each with price_sync failure → escalation flagged.
for i in range(3):
run_id = f"esc-{i}"
await _make_run(run_id, status="failed", offset=i,
error_message="ESCALATED: phase price_sync failed 3 consecutive runs")
await sqlite_service.record_phase_failure(run_id, "price_sync", "IntegrityError: X")
r = client.get("/api/sync/health")
data = r.json()
assert data["escalation_phase"] == "price_sync"
assert data["is_healthy"] is False
assert data["recent_phase_failures"]["price_sync"] == 3
assert "ESCALATED" in (data["last_halt_reason"] or "")
async def test_health_one_phase_failure_still_warning_not_healthy():
await _make_run("recent-fail", status="completed")
await sqlite_service.record_phase_failure("recent-fail", "invoice_check", "err")
r = client.get("/api/sync/health")
data = r.json()
# 1 recent phase failure → is_healthy stays True (<=1 tolerance); healthy
assert data["is_healthy"] is True
assert data["recent_phase_failures"]["invoice_check"] == 1