diff --git a/DESIGN.md b/DESIGN.md index baa1511..fd90471 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -160,9 +160,12 @@ Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors r | ALREADY_IMPORTED | `--info` | `--info-light` | none | | CANCELLED | `--cancelled` | `--cancelled-light` | none | | DELETED_IN_ROA | `--cancelled` | `--cancelled-light` | none | +| MALFORMED | `--compare` | `--compare-light` | `0 0 8px 2px rgba(234,88,12,0.35)` | **Design rule:** Problems glow, success is calm. The operator's eye is pulled to rows that need action. +**ERROR vs MALFORMED:** ERROR red signals a runtime issue operators can fix on our side (Oracle hiccup, network, stale state). MALFORMED orange signals the payload itself is broken at the source — the operator should escalate to GoMag rather than keep retrying. Visually distinct colors make the diagnostic path obvious at a glance. + ## Spacing - **Base unit:** 4px - **Density:** Comfortable — not cramped, not wasteful diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py index fcbbb66..ee667d1 100644 --- a/api/app/routers/sync.py +++ b/api/app/routers/sync.py @@ -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.""" diff --git a/api/app/services/sqlite_service.py b/api/app/services/sqlite_service.py index 30c548e..1f264a6 100644 --- a/api/app/services/sqlite_service.py +++ b/api/app/services/sqlite_service.py @@ -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, diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css index 132c576..52a7cbd 100644 --- a/api/app/static/css/style.css +++ b/api/app/static/css/style.css @@ -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 { diff --git a/api/app/static/js/dashboard.js b/api/app/static/js/dashboard.js index b8c7c92..e3c2cec 100644 --- a/api/app/static/js/dashboard.js +++ b/api/app/static/js/dashboard.js @@ -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')); diff --git a/api/app/static/js/logs.js b/api/app/static/js/logs.js index b0d3750..6e9870b 100644 --- a/api/app/static/js/logs.js +++ b/api/app/static/js/logs.js @@ -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 diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js index 344faed..e2c4403 100644 --- a/api/app/static/js/shared.js +++ b/api/app/static/js/shared.js @@ -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 'Eroare'; case ORDER_STATUS.CANCELLED: return 'Anulat'; case ORDER_STATUS.DELETED_IN_ROA: return 'Sters din ROA'; + case ORDER_STATUS.MALFORMED: return 'Defect'; default: return `${esc(status)}`; } } @@ -1037,6 +1039,8 @@ function statusDot(status) { case ORDER_STATUS.ERROR: case 'FAILED': return ''; + case ORDER_STATUS.MALFORMED: + return ''; case ORDER_STATUS.CANCELLED: case ORDER_STATUS.DELETED_IN_ROA: return ''; diff --git a/api/app/templates/base.html b/api/app/templates/base.html index 3471411..8fb1d80 100644 --- a/api/app/templates/base.html +++ b/api/app/templates/base.html @@ -19,7 +19,7 @@ {% set rp = request.scope.get('root_path', '') %} - +
@@ -169,7 +169,7 @@ - + + {% endblock %} diff --git a/api/app/templates/logs.html b/api/app/templates/logs.html index 6128441..97fc18b 100644 --- a/api/app/templates/logs.html +++ b/api/app/templates/logs.html @@ -63,6 +63,7 @@ + @@ -109,5 +110,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/api/tests/test_sync_health_endpoint.py b/api/tests/test_sync_health_endpoint.py new file mode 100644 index 0000000..f9feebe --- /dev/null +++ b/api/tests/test_sync_health_endpoint.py @@ -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