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:
@@ -160,9 +160,12 @@ Strategy: invert surfaces, reduce accent saturation ~15%, keep semantic colors r
|
|||||||
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
|
| ALREADY_IMPORTED | `--info` | `--info-light` | none |
|
||||||
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
|
| CANCELLED | `--cancelled` | `--cancelled-light` | none |
|
||||||
| DELETED_IN_ROA | `--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.
|
**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
|
## Spacing
|
||||||
- **Base unit:** 4px
|
- **Base unit:** 4px
|
||||||
- **Density:** Comfortable — not cramped, not wasteful
|
- **Density:** Comfortable — not cramped, not wasteful
|
||||||
|
|||||||
@@ -160,6 +160,52 @@ async def sync_status():
|
|||||||
return result
|
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")
|
@router.get("/api/sync/history")
|
||||||
async def sync_history(page: int = 1, per_page: int = 20):
|
async def sync_history(page: int = 1, per_page: int = 20):
|
||||||
"""Get sync run history."""
|
"""Get sync run history."""
|
||||||
|
|||||||
@@ -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),
|
"error": status_counts.get(OrderStatus.ERROR.value, 0),
|
||||||
"already_imported": status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
|
"already_imported": status_counts.get(OrderStatus.ALREADY_IMPORTED.value, 0),
|
||||||
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
|
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
|
||||||
|
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
|
||||||
"total": sum(status_counts.values())
|
"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),
|
"skipped": status_counts.get(OrderStatus.SKIPPED.value, 0),
|
||||||
"error": status_counts.get(OrderStatus.ERROR.value, 0),
|
"error": status_counts.get(OrderStatus.ERROR.value, 0),
|
||||||
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
|
"cancelled": status_counts.get(OrderStatus.CANCELLED.value, 0),
|
||||||
|
"malformed": status_counts.get(OrderStatus.MALFORMED.value, 0),
|
||||||
"total": sum(status_counts.values()),
|
"total": sum(status_counts.values()),
|
||||||
"uninvoiced_sqlite": uninvoiced_sqlite,
|
"uninvoiced_sqlite": uninvoiced_sqlite,
|
||||||
"uninvoiced_old": uninvoiced_old,
|
"uninvoiced_old": uninvoiced_old,
|
||||||
|
|||||||
@@ -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-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-red { background: var(--error); box-shadow: 0 0 8px 2px rgba(220,38,38,0.35); }
|
||||||
.dot-gray { background: var(--cancelled); }
|
.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); }
|
.dot-blue { background: var(--info); }
|
||||||
|
|
||||||
/* ── Flat row (mobile + desktop) ────────────────── */
|
/* ── 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.running { background: var(--info); animation: pulse-dot 2s ease-in-out infinite; }
|
||||||
.sync-status-dot.completed { background: var(--success); }
|
.sync-status-dot.completed { background: var(--success); }
|
||||||
.sync-status-dot.failed { background: var(--error); }
|
.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 ──────────────────── */
|
/* ── Custom period range inputs ──────────────────── */
|
||||||
.period-custom-range {
|
.period-custom-range {
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────
|
// ── Sync Controls ─────────────────────────────────
|
||||||
|
|
||||||
async function startSync() {
|
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 {
|
try {
|
||||||
const res = await fetch('/api/sync/start', { method: 'POST' });
|
const res = await fetch('/api/sync/start', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -174,6 +241,7 @@ async function startSync() {
|
|||||||
}
|
}
|
||||||
// Polling will detect the running state — just speed it up immediately
|
// Polling will detect the running state — just speed it up immediately
|
||||||
pollSyncStatus();
|
pollSyncStatus();
|
||||||
|
pollSyncHealth();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Eroare: ' + err.message);
|
alert('Eroare: ' + err.message);
|
||||||
}
|
}
|
||||||
@@ -329,6 +397,7 @@ async function loadDashOrders() {
|
|||||||
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
if (el('cntFact')) el('cntFact').textContent = c.facturate || 0;
|
||||||
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
if (el('cntNef')) el('cntNef').textContent = c.nefacturate || c.uninvoiced || 0;
|
||||||
if (el('cntCanc')) el('cntCanc').textContent = c.cancelled || 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;
|
if (el('cntDiff')) el('cntDiff').textContent = c.diffs || 0;
|
||||||
|
|
||||||
// Attention card
|
// Attention card
|
||||||
@@ -415,6 +484,7 @@ async function loadDashOrders() {
|
|||||||
{ label: 'Fact.', count: c.facturate || 0, value: 'INVOICED', active: activeStatus === 'INVOICED', colorClass: 'fc-green' },
|
{ 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: '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: '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' }
|
{ label: 'Dif.', count: c.diffs || 0, value: 'DIFFS', active: activeStatus === 'DIFFS', colorClass: 'fc-orange' }
|
||||||
], (val) => {
|
], (val) => {
|
||||||
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.filter-pill[data-status]').forEach(b => b.classList.remove('active'));
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ async function loadRunOrders(runId, statusFilter, page) {
|
|||||||
document.getElementById('countError').textContent = counts.error || 0;
|
document.getElementById('countError').textContent = counts.error || 0;
|
||||||
const alreadyEl = document.getElementById('countAlreadyImported');
|
const alreadyEl = document.getElementById('countAlreadyImported');
|
||||||
if (alreadyEl) alreadyEl.textContent = counts.already_imported || 0;
|
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 tbody = document.getElementById('runOrdersBody');
|
||||||
const orders = data.orders || [];
|
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: '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: '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: '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));
|
], (val) => filterOrders(val));
|
||||||
|
|
||||||
// Orders pagination
|
// Orders pagination
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const ORDER_STATUS = Object.freeze({
|
|||||||
ERROR: 'ERROR',
|
ERROR: 'ERROR',
|
||||||
CANCELLED: 'CANCELLED',
|
CANCELLED: 'CANCELLED',
|
||||||
DELETED_IN_ROA: 'DELETED_IN_ROA',
|
DELETED_IN_ROA: 'DELETED_IN_ROA',
|
||||||
|
MALFORMED: 'MALFORMED',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── HTML escaping ─────────────────────────────────
|
// ── HTML escaping ─────────────────────────────────
|
||||||
@@ -519,6 +520,7 @@ function orderStatusBadge(status) {
|
|||||||
case ORDER_STATUS.ERROR: return '<span class="badge bg-danger">Eroare</span>';
|
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.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.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>`;
|
default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1039,8 @@ function statusDot(status) {
|
|||||||
case ORDER_STATUS.ERROR:
|
case ORDER_STATUS.ERROR:
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
return '<span class="dot dot-red"></span>';
|
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.CANCELLED:
|
||||||
case ORDER_STATUS.DELETED_IN_ROA:
|
case ORDER_STATUS.DELETED_IN_ROA:
|
||||||
return '<span class="dot dot-gray"></span>';
|
return '<span class="dot dot-gray"></span>';
|
||||||
|
|||||||
@@ -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@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">
|
<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', '') %}
|
{% 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Navbar (hidden on mobile via CSS) -->
|
<!-- Top Navbar (hidden on mobile via CSS) -->
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<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="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>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
<div class="sync-card-controls">
|
<div class="sync-card-controls">
|
||||||
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
<span id="syncStatusDot" class="sync-status-dot idle"></span>
|
||||||
<span id="syncStatusText" class="text-secondary">Inactiv</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">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<label class="d-flex align-items-center gap-1 text-muted">
|
<label class="d-flex align-items-center gap-1 text-muted">
|
||||||
Auto:
|
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="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="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.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="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">↻</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">↻</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,5 +121,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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.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.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.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>
|
||||||
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
<div class="d-md-none mb-2" id="logsMobileSeg" style="overflow-x:auto"></div>
|
||||||
|
|
||||||
@@ -109,5 +110,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
110
api/tests/test_sync_health_endpoint.py
Normal file
110
api/tests/test_sync_health_endpoint.py
Normal 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
|
||||||
Reference in New Issue
Block a user