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:
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user