feat(sync): add SSE live feed, unified logs page, fix Oracle connection

- Add SSE event bus in sync_service (subscribe/unsubscribe/_emit)
- Add GET /api/sync/stream SSE endpoint for real-time sync progress
- Rewrite logs.html: unified runs table + live feed + summary + filters
- Rewrite logs.js: SSE EventSource client, run selection, pagination
- Dashboard: clickable runs navigate to /logs?run=, sync started banner
- Remove "Import Comenzi" nav item, delete sync_detail.html
- Add error_message column to sync_runs table with migration
- Fix: export TNS_ADMIN as OS env var so oracledb finds tnsnames.ora
- Fix: use get_oracle_connection() instead of direct pool.acquire()
- Fix: CRM_POLITICI_PRET_ART INSERT to match actual table schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 18:08:09 +02:00
parent 97699fa0e5
commit 650e98539e
13 changed files with 638 additions and 359 deletions

View File

@@ -19,6 +19,10 @@ def init_oracle():
instantclient_path = settings.INSTANTCLIENTPATH instantclient_path = settings.INSTANTCLIENTPATH
dsn = settings.ORACLE_DSN dsn = settings.ORACLE_DSN
# Ensure TNS_ADMIN is set as OS env var so oracledb can find tnsnames.ora
if settings.TNS_ADMIN:
os.environ['TNS_ADMIN'] = settings.TNS_ADMIN
if force_thin: if force_thin:
logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}") logger.info(f"FORCE_THIN_MODE=true: thin mode for {dsn}")
elif instantclient_path: elif instantclient_path:
@@ -68,7 +72,8 @@ CREATE TABLE IF NOT EXISTS sync_runs (
imported INTEGER DEFAULT 0, imported INTEGER DEFAULT 0,
skipped INTEGER DEFAULT 0, skipped INTEGER DEFAULT 0,
errors INTEGER DEFAULT 0, errors INTEGER DEFAULT 0,
json_files INTEGER DEFAULT 0 json_files INTEGER DEFAULT 0,
error_message TEXT
); );
CREATE TABLE IF NOT EXISTS import_orders ( CREATE TABLE IF NOT EXISTS import_orders (
@@ -129,6 +134,12 @@ def init_sqlite():
if col not in cols: if col not in cols:
conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}") conn.execute(f"ALTER TABLE missing_skus ADD COLUMN {col} {typedef}")
logger.info(f"Migrated missing_skus: added column {col}") logger.info(f"Migrated missing_skus: added column {col}")
# Migrate sync_runs: add error_message column
cursor = conn.execute("PRAGMA table_info(sync_runs)")
sync_cols = {row[1] for row in cursor.fetchall()}
if "error_message" not in sync_cols:
conn.execute("ALTER TABLE sync_runs ADD COLUMN error_message TEXT")
logger.info("Migrated sync_runs: added column error_message")
conn.commit() conn.commit()
except Exception as e: except Exception as e:
logger.warning(f"Migration check failed: {e}") logger.warning(f"Migration check failed: {e}")

View File

@@ -1,6 +1,10 @@
import asyncio
import json
from fastapi import APIRouter, Request, BackgroundTasks from fastapi import APIRouter, Request, BackgroundTasks
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from starlette.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -16,27 +20,46 @@ class ScheduleConfig(BaseModel):
interval_minutes: int = 5 interval_minutes: int = 5
# HTML pages # SSE streaming endpoint
@router.get("/sync", response_class=HTMLResponse) @router.get("/api/sync/stream")
async def sync_page(request: Request): async def sync_stream(request: Request):
return templates.TemplateResponse("dashboard.html", {"request": request}) """SSE stream for real-time sync progress."""
q = sync_service.subscribe()
async def event_generator():
try:
while True:
# Check if client disconnected
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(q.get(), timeout=15.0)
yield f"data: {json.dumps(event)}\n\n"
if event.get("type") in ("completed", "failed"):
break
except asyncio.TimeoutError:
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
finally:
sync_service.unsubscribe(q)
@router.get("/sync/run/{run_id}", response_class=HTMLResponse) return StreamingResponse(
async def sync_detail_page(request: Request, run_id: str): event_generator(),
return templates.TemplateResponse("sync_detail.html", {"request": request, "run_id": run_id}) media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
# API endpoints # API endpoints
@router.post("/api/sync/start") @router.post("/api/sync/start")
async def start_sync(background_tasks: BackgroundTasks): async def start_sync(background_tasks: BackgroundTasks):
"""Trigger a sync run in the background.""" """Trigger a sync run in the background."""
status = await sync_service.get_sync_status() result = await sync_service.prepare_sync()
if status.get("status") == "running": if result.get("error"):
return {"error": "Sync already running", "run_id": status.get("run_id")} return {"error": result["error"], "run_id": result.get("run_id")}
background_tasks.add_task(sync_service.run_sync) run_id = result["run_id"]
return {"message": "Sync started"} background_tasks.add_task(sync_service.run_sync, run_id=run_id)
return {"message": "Sync started", "run_id": run_id}
@router.post("/api/sync/stop") @router.post("/api/sync/stop")
@@ -61,8 +84,8 @@ async def sync_history(page: int = 1, per_page: int = 20):
@router.get("/logs", response_class=HTMLResponse) @router.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request): async def logs_page(request: Request, run: str = None):
return templates.TemplateResponse("logs.html", {"request": request}) return templates.TemplateResponse("logs.html", {"request": request, "selected_run": run or ""})
@router.get("/api/sync/run/{run_id}") @router.get("/api/sync/run/{run_id}")

View File

@@ -91,6 +91,8 @@ def import_single_order(order, id_pol: int = None, id_sectie: int = None) -> dic
order_number = clean_web_text(order.number) order_number = clean_web_text(order.number)
order_date = convert_web_date(order.date) order_date = convert_web_date(order.date)
if database.pool is None:
raise RuntimeError("Oracle pool not initialized")
with database.pool.acquire() as conn: with database.pool.acquire() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# Step 1: Process partner # Step 1: Process partner

View File

@@ -20,7 +20,8 @@ async def create_sync_run(run_id: str, json_files: int = 0):
async def update_sync_run(run_id: str, status: str, total_orders: int = 0, async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
imported: int = 0, skipped: int = 0, errors: int = 0): imported: int = 0, skipped: int = 0, errors: int = 0,
error_message: str = None):
"""Update sync run with results.""" """Update sync run with results."""
db = await get_sqlite() db = await get_sqlite()
try: try:
@@ -31,9 +32,10 @@ async def update_sync_run(run_id: str, status: str, total_orders: int = 0,
total_orders = ?, total_orders = ?,
imported = ?, imported = ?,
skipped = ?, skipped = ?,
errors = ? errors = ?,
error_message = ?
WHERE run_id = ? WHERE run_id = ?
""", (status, total_orders, imported, skipped, errors, run_id)) """, (status, total_orders, imported, skipped, errors, error_message, run_id))
await db.commit() await db.commit()
finally: finally:
await db.close() await db.close()

View File

@@ -13,6 +13,33 @@ logger = logging.getLogger(__name__)
_sync_lock = asyncio.Lock() _sync_lock = asyncio.Lock()
_current_sync = None # dict with run_id, status, progress info _current_sync = None # dict with run_id, status, progress info
# SSE subscriber system
_subscribers: list[asyncio.Queue] = []
def subscribe() -> asyncio.Queue:
"""Subscribe to sync events. Returns a queue that will receive event dicts."""
q = asyncio.Queue()
_subscribers.append(q)
return q
def unsubscribe(q: asyncio.Queue):
"""Unsubscribe from sync events."""
try:
_subscribers.remove(q)
except ValueError:
pass
async def _emit(event: dict):
"""Push an event to all subscriber queues."""
for q in _subscribers:
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
async def get_sync_status(): async def get_sync_status():
"""Get current sync status.""" """Get current sync status."""
@@ -21,7 +48,25 @@ async def get_sync_status():
return {"status": "idle"} return {"status": "idle"}
async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict: async def prepare_sync(id_pol: int = None, id_sectie: int = None) -> dict:
"""Prepare a sync run - creates run_id and sets initial state.
Returns {"run_id": ..., "status": "starting"} or {"error": ...} if already running.
"""
global _current_sync
if _sync_lock.locked():
return {"error": "Sync already running", "run_id": _current_sync.get("run_id") if _current_sync else None}
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
_current_sync = {
"run_id": run_id,
"status": "running",
"started_at": datetime.now().isoformat(),
"progress": "Starting..."
}
return {"run_id": run_id, "status": "starting"}
async def run_sync(id_pol: int = None, id_sectie: int = None, run_id: str = None) -> dict:
"""Run a full sync cycle. Returns summary dict.""" """Run a full sync cycle. Returns summary dict."""
global _current_sync global _current_sync
@@ -29,30 +74,33 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
return {"error": "Sync already running"} return {"error": "Sync already running"}
async with _sync_lock: async with _sync_lock:
run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6] # Use provided run_id or generate one
_current_sync = { if not run_id:
"run_id": run_id, run_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:6]
"status": "running", _current_sync = {
"started_at": datetime.now().isoformat(), "run_id": run_id,
"progress": "Reading JSON files..." "status": "running",
} "started_at": datetime.now().isoformat(),
"progress": "Reading JSON files..."
}
_current_sync["progress"] = "Reading JSON files..."
await _emit({"type": "phase", "run_id": run_id, "message": "Reading JSON files..."})
try: try:
# Step 1: Read orders # Step 1: Read orders
orders, json_count = order_reader.read_json_orders() orders, json_count = order_reader.read_json_orders()
await sqlite_service.create_sync_run(run_id, json_count) await sqlite_service.create_sync_run(run_id, json_count)
await _emit({"type": "phase", "run_id": run_id, "message": f"Found {len(orders)} orders in {json_count} files"})
if not orders: if not orders:
await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0) await sqlite_service.update_sync_run(run_id, "completed", 0, 0, 0, 0)
_current_sync = None summary = {"run_id": run_id, "status": "completed", "message": "No orders found", "json_files": json_count}
return { await _emit({"type": "completed", "run_id": run_id, "summary": summary})
"run_id": run_id, return summary
"status": "completed",
"message": "No orders found",
"json_files": json_count
}
_current_sync["progress"] = f"Validating {len(orders)} orders..." _current_sync["progress"] = f"Validating {len(orders)} orders..."
await _emit({"type": "phase", "run_id": run_id, "message": f"Validating {len(orders)} orders..."})
# Step 2a: Find new orders (not yet in Oracle) # Step 2a: Find new orders (not yet in Oracle)
all_order_numbers = [o.number for o in orders] all_order_numbers = [o.number for o in orders]
@@ -65,6 +113,8 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
validation = await asyncio.to_thread(validation_service.validate_skus, all_skus) validation = await asyncio.to_thread(validation_service.validate_skus, all_skus)
importable, skipped = validation_service.classify_orders(orders, validation) importable, skipped = validation_service.classify_orders(orders, validation)
await _emit({"type": "phase", "run_id": run_id, "message": f"{len(importable)} importable, {len(skipped)} skipped (missing SKUs)"})
# Step 2c: Build SKU context from skipped orders # Step 2c: Build SKU context from skipped orders
sku_context = {} # {sku: {"orders": [], "customers": []}} sku_context = {} # {sku: {"orders": [], "customers": []}}
for order, missing_skus_list in skipped: for order, missing_skus_list in skipped:
@@ -100,6 +150,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
id_pol = id_pol or settings.ID_POL id_pol = id_pol or settings.ID_POL
if id_pol and importable: if id_pol and importable:
_current_sync["progress"] = "Validating prices..." _current_sync["progress"] = "Validating prices..."
await _emit({"type": "phase", "run_id": run_id, "message": "Validating prices..."})
# Gather all CODMATs from importable orders # Gather all CODMATs from importable orders
all_codmats = set() all_codmats = set()
for order in importable: for order in importable:
@@ -124,7 +175,7 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
price_result["missing_price"], id_pol price_result["missing_price"], id_pol
) )
# Step 3: Record skipped orders # Step 3: Record skipped orders + emit events
for order, missing_skus in skipped: for order, missing_skus in skipped:
customer = order.billing.company_name or \ customer = order.billing.company_name or \
f"{order.billing.firstname} {order.billing.lastname}" f"{order.billing.firstname} {order.billing.lastname}"
@@ -137,13 +188,20 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
missing_skus=missing_skus, missing_skus=missing_skus,
items_count=len(order.items) items_count=len(order.items)
) )
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"status": "SKIPPED", "missing_skus": missing_skus,
"items_count": len(order.items), "progress": f"0/{len(importable)}"
})
# Step 4: Import valid orders # Step 4: Import valid orders
imported_count = 0 imported_count = 0
error_count = 0 error_count = 0
for i, order in enumerate(importable): for i, order in enumerate(importable):
_current_sync["progress"] = f"Importing {i+1}/{len(importable)}: #{order.number}" progress_str = f"{i+1}/{len(importable)}"
_current_sync["progress"] = f"Importing {progress_str}: #{order.number}"
result = await asyncio.to_thread( result = await asyncio.to_thread(
import_service.import_single_order, import_service.import_single_order,
@@ -164,6 +222,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
id_partener=result["id_partener"], id_partener=result["id_partener"],
items_count=len(order.items) items_count=len(order.items)
) )
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"status": "IMPORTED", "items_count": len(order.items),
"id_comanda": result["id_comanda"], "progress": progress_str
})
else: else:
error_count += 1 error_count += 1
await sqlite_service.add_import_order( await sqlite_service.add_import_order(
@@ -176,6 +240,12 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
error_message=result["error"], error_message=result["error"],
items_count=len(order.items) items_count=len(order.items)
) )
await _emit({
"type": "order_result", "run_id": run_id,
"order_number": order.number, "customer_name": customer,
"status": "ERROR", "error_message": result["error"],
"items_count": len(order.items), "progress": progress_str
})
# Safety: stop if too many errors # Safety: stop if too many errors
if error_count > 10: if error_count > 10:
@@ -204,14 +274,22 @@ async def run_sync(id_pol: int = None, id_sectie: int = None) -> dict:
f"Sync {run_id} completed: {imported_count} imported, " f"Sync {run_id} completed: {imported_count} imported, "
f"{len(skipped)} skipped, {error_count} errors" f"{len(skipped)} skipped, {error_count} errors"
) )
await _emit({"type": "completed", "run_id": run_id, "summary": summary})
return summary return summary
except Exception as e: except Exception as e:
logger.error(f"Sync {run_id} failed: {e}") logger.error(f"Sync {run_id} failed: {e}")
await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1) await sqlite_service.update_sync_run(run_id, "failed", 0, 0, 0, 1, error_message=str(e))
_current_sync["error"] = str(e)
await _emit({"type": "failed", "run_id": run_id, "error": str(e)})
return {"run_id": run_id, "status": "failed", "error": str(e)} return {"run_id": run_id, "status": "failed", "error": str(e)}
finally: finally:
_current_sync = None # Keep _current_sync for 10 seconds so status endpoint can show final result
async def _clear_current_sync():
await asyncio.sleep(10)
global _current_sync
_current_sync = None
asyncio.ensure_future(_clear_current_sync())
def stop_sync(): def stop_sync():

View File

@@ -17,7 +17,8 @@ def validate_skus(skus: set[str]) -> dict:
direct = set() direct = set()
sku_list = list(skus) sku_list = list(skus)
with database.pool.acquire() as conn: conn = database.get_oracle_connection()
try:
with conn.cursor() as cur: with conn.cursor() as cur:
# Check in batches of 500 # Check in batches of 500
for i in range(0, len(sku_list), 500): for i in range(0, len(sku_list), 500):
@@ -44,6 +45,8 @@ def validate_skus(skus: set[str]) -> dict:
""", params2) """, params2)
for row in cur: for row in cur:
direct.add(row[0]) direct.add(row[0])
finally:
database.pool.release(conn)
missing = skus - mapped - direct missing = skus - mapped - direct
@@ -80,7 +83,8 @@ def find_new_orders(order_numbers: list[str]) -> set[str]:
existing = set() existing = set()
num_list = list(order_numbers) num_list = list(order_numbers)
with database.pool.acquire() as conn: conn = database.get_oracle_connection()
try:
with conn.cursor() as cur: with conn.cursor() as cur:
for i in range(0, len(num_list), 500): for i in range(0, len(num_list), 500):
batch = num_list[i:i+500] batch = num_list[i:i+500]
@@ -93,6 +97,8 @@ def find_new_orders(order_numbers: list[str]) -> set[str]:
""", params) """, params)
for row in cur: for row in cur:
existing.add(row[0]) existing.add(row[0])
finally:
database.pool.release(conn)
new_orders = set(order_numbers) - existing new_orders = set(order_numbers) - existing
logger.info(f"Order check: {len(new_orders)} new, {len(existing)} already exist out of {len(order_numbers)} total") logger.info(f"Order check: {len(new_orders)} new, {len(existing)} already exist out of {len(order_numbers)} total")
@@ -109,7 +115,8 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
ids_with_price = set() ids_with_price = set()
codmat_list = list(codmats) codmat_list = list(codmats)
with database.pool.acquire() as conn: conn = database.get_oracle_connection()
try:
with conn.cursor() as cur: with conn.cursor() as cur:
# Step 1: Get ID_ARTICOL for each CODMAT # Step 1: Get ID_ARTICOL for each CODMAT
for i in range(0, len(codmat_list), 500): for i in range(0, len(codmat_list), 500):
@@ -138,6 +145,8 @@ def validate_prices(codmats: set[str], id_pol: int) -> dict:
""", params) """, params)
for row in cur: for row in cur:
ids_with_price.add(row[0]) ids_with_price.add(row[0])
finally:
database.pool.release(conn)
# Map back to CODMATs # Map back to CODMATs
has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price} has_price = {cm for cm, aid in codmat_to_id.items() if aid in ids_with_price}
@@ -151,7 +160,8 @@ def ensure_prices(codmats: set[str], id_pol: int):
if not codmats: if not codmats:
return return
with database.pool.acquire() as conn: conn = database.get_oracle_connection()
try:
with conn.cursor() as cur: with conn.cursor() as cur:
# Get ID_VALUTA for this policy # Get ID_VALUTA for this policy
cur.execute(""" cur.execute("""
@@ -176,16 +186,18 @@ def ensure_prices(codmats: set[str], id_pol: int):
cur.execute(""" cur.execute("""
INSERT INTO CRM_POLITICI_PRET_ART INSERT INTO CRM_POLITICI_PRET_ART
(ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_COMANDA, ID_VALUTA, (ID_POL_ART, ID_POL, ID_ARTICOL, PRET, ID_VALUTA,
ID_UTIL, DATAORA, PROC_TVAV, ID_PARTR, ID_PARTZ, ID_UTIL, DATAORA, PROC_TVAV,
PRETFTVA, PRETCTVA, CANTITATE, ID_UM, PRET_MIN, PRET_MIN_TVA) PRETFTVA, PRETCTVA)
VALUES VALUES
(SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, NULL, :id_valuta, (SEQ_CRM_POLITICI_PRET_ART.NEXTVAL, :id_pol, :id_articol, 0, :id_valuta,
-3, SYSDATE, 1.19, NULL, NULL, -3, SYSDATE, 1.19,
0, 0, 0, NULL, 0, 0) 0, 0)
""", {"id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta}) """, {"id_pol": id_pol, "id_articol": id_articol, "id_valuta": id_valuta})
logger.info(f"Pret 0 adaugat pentru CODMAT {codmat} in politica {id_pol}") logger.info(f"Pret 0 adaugat pentru CODMAT {codmat} in politica {id_pol}")
conn.commit() conn.commit()
finally:
database.pool.release(conn)
logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}") logger.info(f"Ensure prices done: {len(codmats)} CODMATs processed for policy {id_pol}")

View File

@@ -212,3 +212,73 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* Live Feed */
.live-feed {
max-height: 300px;
overflow-y: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.8125rem;
scroll-behavior: smooth;
}
.feed-entry {
padding: 0.35rem 0.75rem;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.feed-entry:last-child {
border-bottom: none;
}
.feed-entry.phase {
background-color: #eff6ff;
color: #1e40af;
}
.feed-entry.error {
background-color: #fef2f2;
color: #991b1b;
}
.feed-entry.success {
color: #166534;
}
.feed-entry .feed-time {
color: #94a3b8;
white-space: nowrap;
min-width: 5rem;
}
.feed-entry .feed-icon {
min-width: 1.25rem;
text-align: center;
}
.feed-entry .feed-msg {
flex: 1;
word-break: break-word;
}
/* Live pulse animation */
.live-pulse {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Clickable table rows */
.table-hover tbody tr[data-href] {
cursor: pointer;
}
.table-hover tbody tr[data-href]:hover {
background-color: #e2e8f0;
}

View File

@@ -114,7 +114,7 @@ async function loadSyncHistory() {
} }
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger'; const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
return `<tr style="cursor:pointer" onclick="window.location='/sync/run/${esc(r.run_id)}'"> return `<tr style="cursor:pointer" onclick="window.location='/logs?run=${esc(r.run_id)}'">
<td>${started}</td> <td>${started}</td>
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td> <td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
<td>${r.total_orders || 0}</td> <td>${r.total_orders || 0}</td>
@@ -192,6 +192,16 @@ async function startSync() {
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
alert(data.error); alert(data.error);
return;
}
// Show banner with link to live logs
if (data.run_id) {
const banner = document.getElementById('syncStartedBanner');
const link = document.getElementById('syncRunLink');
if (banner && link) {
link.href = '/logs?run=' + encodeURIComponent(data.run_id);
banner.classList.remove('d-none');
}
} }
loadDashboard(); loadDashboard();
} catch (err) { } catch (err) {

View File

@@ -1,15 +1,25 @@
// logs.js - Jurnale Import page logic // logs.js - Unified Logs page with SSE live feed
let currentRunId = null;
let eventSource = null;
let runsPage = 1;
let liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
function esc(s) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
return String(s) return String(s)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/</g, '&lt;') .replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function fmtTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) { return ''; }
}
function fmtDatetime(iso) { function fmtDatetime(iso) {
if (!iso) return '-'; if (!iso) return '-';
try { try {
@@ -17,9 +27,7 @@ function fmtDatetime(iso) {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit' hour: '2-digit', minute: '2-digit'
}); });
} catch (e) { } catch (e) { return iso; }
return iso;
}
} }
function fmtDuration(startedAt, finishedAt) { function fmtDuration(startedAt, finishedAt) {
@@ -41,50 +49,252 @@ function statusBadge(status) {
} }
function runStatusBadge(status) { function runStatusBadge(status) {
switch ((status || '').toUpperCase()) { switch ((status || '').toLowerCase()) {
case 'SUCCESS': return '<span class="badge bg-success ms-1">SUCCESS</span>'; case 'completed': return '<span class="badge bg-success">completed</span>';
case 'ERROR': return '<span class="badge bg-danger ms-1">ERROR</span>'; case 'running': return '<span class="badge bg-primary">running</span>';
case 'RUNNING': return '<span class="badge bg-primary ms-1">RUNNING</span>'; case 'failed': return '<span class="badge bg-danger">failed</span>';
case 'PARTIAL': return '<span class="badge bg-warning text-dark ms-1">PARTIAL</span>'; default: return `<span class="badge bg-secondary">${esc(status)}</span>`;
default: return `<span class="badge bg-secondary ms-1">${esc(status || '')}</span>`;
} }
} }
async function loadRuns() { // ── Runs Table ──────────────────────────────────
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">Se incarca...</option>'; async function loadRuns(page) {
if (page != null) runsPage = page;
const perPage = 20;
try { try {
const res = await fetch('/api/sync/history?per_page=20'); const res = await fetch(`/api/sync/history?page=${runsPage}&per_page=${perPage}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json(); const data = await res.json();
const runs = data.runs || []; const runs = data.runs || [];
if (runs.length === 0) { const total = data.total || runs.length;
sel.innerHTML = '<option value="">Nu exista sync runs</option>';
return;
}
// Populate dropdown
const sel = document.getElementById('runSelector');
sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' + sel.innerHTML = '<option value="">-- Selecteaza un sync run --</option>' +
runs.map(r => { runs.map(r => {
const date = fmtDatetime(r.started_at); const date = fmtDatetime(r.started_at);
const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`; const stats = `${r.total_orders || 0} total / ${r.imported || 0} ok / ${r.errors || 0} err`;
const statusText = (r.status || '').toUpperCase(); return `<option value="${esc(r.run_id)}"${r.run_id === currentRunId ? ' selected' : ''}>[${(r.status||'').toUpperCase()}] ${date}${stats}</option>`;
return `<option value="${esc(r.run_id)}">[${statusText}] ${date}${stats}</option>`;
}).join(''); }).join('');
// Populate table
const tbody = document.getElementById('runsTableBody');
if (runs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Niciun sync run</td></tr>';
} else {
tbody.innerHTML = runs.map(r => {
const started = r.started_at ? new Date(r.started_at).toLocaleString('ro-RO', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
const duration = fmtDuration(r.started_at, r.finished_at);
const statusClass = r.status === 'completed' ? 'bg-success' : r.status === 'running' ? 'bg-primary' : 'bg-danger';
const activeClass = r.run_id === currentRunId ? 'table-active' : '';
return `<tr class="${activeClass}" data-href="/logs?run=${esc(r.run_id)}" onclick="selectRun('${esc(r.run_id)}')">
<td>${started}</td>
<td><span class="badge ${statusClass}">${esc(r.status)}</span></td>
<td>${r.total_orders || 0}</td>
<td class="text-success">${r.imported || 0}</td>
<td class="text-warning">${r.skipped || 0}</td>
<td class="text-danger">${r.errors || 0}</td>
<td>${duration}</td>
</tr>`;
}).join('');
}
// Pagination
const pagDiv = document.getElementById('runsTablePagination');
const totalPages = Math.ceil(total / perPage);
if (totalPages > 1) {
pagDiv.innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${runsPage <= 1 ? 'disabled' : ''} onclick="loadRuns(${runsPage - 1})"><i class="bi bi-chevron-left"></i></button>
<small class="text-muted">${runsPage} / ${totalPages}</small>
<button class="btn btn-sm btn-outline-secondary" ${runsPage >= totalPages ? 'disabled' : ''} onclick="loadRuns(${runsPage + 1})"><i class="bi bi-chevron-right"></i></button>
`;
} else {
pagDiv.innerHTML = '';
}
} catch (err) { } catch (err) {
sel.innerHTML = '<option value="">Eroare la incarcare: ' + esc(err.message) + '</option>'; document.getElementById('runsTableBody').innerHTML = `<tr><td colspan="7" class="text-center text-danger py-3">${esc(err.message)}</td></tr>`;
} }
} }
// ── Run Selection ───────────────────────────────
async function selectRun(runId) {
if (eventSource) { eventSource.close(); eventSource = null; }
currentRunId = runId;
// Update URL without reload
const url = new URL(window.location);
if (runId) { url.searchParams.set('run', runId); } else { url.searchParams.delete('run'); }
history.replaceState(null, '', url);
// Highlight active row in table
document.querySelectorAll('#runsTableBody tr').forEach(tr => {
tr.classList.toggle('table-active', tr.getAttribute('data-href') === `/logs?run=${runId}`);
});
// Update dropdown
document.getElementById('runSelector').value = runId || '';
if (!runId) {
document.getElementById('runDetailSection').style.display = 'none';
return;
}
document.getElementById('runDetailSection').style.display = '';
// Check if this run is currently active
try {
const statusRes = await fetch('/api/sync/status');
const statusData = await statusRes.json();
if (statusData.status === 'running' && statusData.run_id === runId) {
startLiveFeed(runId);
return;
}
} catch (e) { /* fall through to historical load */ }
// Load historical data
document.getElementById('liveFeedCard').style.display = 'none';
await loadRunLog(runId);
}
// ── Live SSE Feed ───────────────────────────────
function startLiveFeed(runId) {
liveCounts = { imported: 0, skipped: 0, errors: 0, total: 0 };
// Show live feed card, clear it
const feedCard = document.getElementById('liveFeedCard');
feedCard.style.display = '';
document.getElementById('liveFeed').innerHTML = '';
document.getElementById('logsBody').innerHTML = '';
// Reset summary
document.getElementById('sum-total').textContent = '-';
document.getElementById('sum-imported').textContent = '0';
document.getElementById('sum-skipped').textContent = '0';
document.getElementById('sum-errors').textContent = '0';
document.getElementById('sum-duration').textContent = 'live...';
connectSSE();
}
function connectSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/api/sync/stream');
eventSource.onmessage = function(e) {
let event;
try { event = JSON.parse(e.data); } catch (err) { return; }
if (event.type === 'keepalive') return;
if (event.type === 'phase') {
appendFeedEntry('phase', event.message);
}
else if (event.type === 'order_result') {
const icon = event.status === 'IMPORTED' ? '✅' : event.status === 'SKIPPED' ? '⏭️' : '❌';
const progressText = event.progress ? `[${event.progress}]` : '';
appendFeedEntry(
event.status === 'ERROR' ? 'error' : event.status === 'IMPORTED' ? 'success' : '',
`${progressText} #${event.order_number} ${event.customer_name || ''}${icon} ${event.status}${event.error_message ? ' — ' + event.error_message : ''}`
);
addOrderRow(event);
updateLiveSummary(event);
}
else if (event.type === 'completed') {
appendFeedEntry('phase', '🏁 Sync completed');
eventSource.close();
eventSource = null;
document.querySelector('.live-pulse')?.remove();
// Reload full data from REST after short delay
setTimeout(() => {
loadRunLog(currentRunId);
loadRuns();
}, 500);
}
else if (event.type === 'failed') {
appendFeedEntry('error', '💥 Sync failed: ' + (event.error || 'Unknown error'));
eventSource.close();
eventSource = null;
document.querySelector('.live-pulse')?.remove();
setTimeout(() => {
loadRunLog(currentRunId);
loadRuns();
}, 500);
}
};
eventSource.onerror = function() {
// SSE disconnected — try to load historical data
eventSource.close();
eventSource = null;
setTimeout(() => loadRunLog(currentRunId), 1000);
};
}
function appendFeedEntry(type, message) {
const feed = document.getElementById('liveFeed');
const now = new Date().toLocaleTimeString('ro-RO', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const typeClass = type ? ` ${type}` : '';
const iconMap = { phase: '', error: '❌', success: '✅' };
const icon = iconMap[type] || '▶';
const entry = document.createElement('div');
entry.className = `feed-entry${typeClass}`;
entry.innerHTML = `<span class="feed-time">${now}</span><span class="feed-icon">${icon}</span><span class="feed-msg">${esc(message)}</span>`;
feed.appendChild(entry);
// Auto-scroll to bottom
feed.scrollTop = feed.scrollHeight;
}
function addOrderRow(event) {
const tbody = document.getElementById('logsBody');
const status = (event.status || '').toUpperCase();
let details = '';
if (event.error_message) {
details = `<span class="text-danger">${esc(event.error_message)}</span>`;
}
if (event.missing_skus && Array.isArray(event.missing_skus) && event.missing_skus.length > 0) {
details += `<div class="mt-1">${event.missing_skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('')}</div>`;
}
if (event.id_comanda) {
details += `<small class="text-success">ID: ${event.id_comanda}</small>`;
}
if (!details) details = '<span class="text-muted">-</span>';
const tr = document.createElement('tr');
tr.setAttribute('data-status', status);
tr.innerHTML = `
<td><code>${esc(event.order_number || '-')}</code></td>
<td>${esc(event.customer_name || '-')}</td>
<td class="text-center">${event.items_count ?? '-'}</td>
<td>${statusBadge(status)}</td>
<td>${details}</td>
`;
tbody.appendChild(tr);
}
function updateLiveSummary(event) {
liveCounts.total++;
if (event.status === 'IMPORTED') liveCounts.imported++;
else if (event.status === 'SKIPPED') liveCounts.skipped++;
else if (event.status === 'ERROR') liveCounts.errors++;
document.getElementById('sum-total').textContent = liveCounts.total;
document.getElementById('sum-imported').textContent = liveCounts.imported;
document.getElementById('sum-skipped').textContent = liveCounts.skipped;
document.getElementById('sum-errors').textContent = liveCounts.errors;
}
// ── Historical Run Log ──────────────────────────
async function loadRunLog(runId) { async function loadRunLog(runId) {
const tbody = document.getElementById('logsBody'); const tbody = document.getElementById('logsBody');
const filterRow = document.getElementById('filterRow');
const runSummary = document.getElementById('runSummary');
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Se incarca...</td></tr>';
filterRow.style.display = 'none';
runSummary.style.display = 'none';
try { try {
const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`); const res = await fetch(`/api/sync/run/${encodeURIComponent(runId)}/log`);
@@ -95,40 +305,33 @@ async function loadRunLog(runId) {
const orders = data.orders || []; const orders = data.orders || [];
// Populate summary bar // Populate summary bar
document.getElementById('sum-total').textContent = run.total_orders ?? '-'; document.getElementById('sum-total').textContent = run.total_orders ?? '-';
document.getElementById('sum-imported').textContent = run.imported ?? '-'; document.getElementById('sum-imported').textContent = run.imported ?? '-';
document.getElementById('sum-skipped').textContent = run.skipped ?? '-'; document.getElementById('sum-skipped').textContent = run.skipped ?? '-';
document.getElementById('sum-errors').textContent = run.errors ?? '-'; document.getElementById('sum-errors').textContent = run.errors ?? '-';
document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at); document.getElementById('sum-duration').textContent = fmtDuration(run.started_at, run.finished_at);
runSummary.style.display = '';
if (orders.length === 0) { if (orders.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>'; const runError = run.error_message
filterRow.style.display = 'none'; ? `<tr><td colspan="5" class="text-center py-4"><span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${esc(run.error_message)}</span></td></tr>`
: '<tr><td colspan="5" class="text-center text-muted py-4">Nicio comanda in acest sync run</td></tr>';
tbody.innerHTML = runError;
updateFilterCount(); updateFilterCount();
return; return;
} }
tbody.innerHTML = orders.map(order => { tbody.innerHTML = orders.map(order => {
const status = (order.status || '').toUpperCase(); const status = (order.status || '').toUpperCase();
// Parse missing_skus — API returns JSON string or null
let missingSkuTags = ''; let missingSkuTags = '';
if (order.missing_skus) { if (order.missing_skus) {
try { try {
const skus = typeof order.missing_skus === 'string' const skus = typeof order.missing_skus === 'string' ? JSON.parse(order.missing_skus) : order.missing_skus;
? JSON.parse(order.missing_skus)
: order.missing_skus;
if (Array.isArray(skus) && skus.length > 0) { if (Array.isArray(skus) && skus.length > 0) {
missingSkuTags = '<div class="mt-1">' + missingSkuTags = '<div class="mt-1">' +
skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + skus.map(s => `<code class="me-1 text-warning">${esc(s)}</code>`).join('') + '</div>';
'</div>';
} }
} catch (e) { } catch (e) { /* skip */ }
// malformed JSON — skip
}
} }
const details = order.error_message const details = order.error_message
? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}` ? `<span class="text-danger">${esc(order.error_message)}</span>${missingSkuTags}`
: missingSkuTags || '<span class="text-muted">-</span>'; : missingSkuTags || '<span class="text-muted">-</span>';
@@ -142,75 +345,61 @@ async function loadRunLog(runId) {
</tr>`; </tr>`;
}).join(''); }).join('');
filterRow.style.display = ''; // Reset filter
// Reset filter to "Toate"
document.querySelectorAll('[data-filter]').forEach(btn => { document.querySelectorAll('[data-filter]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all'); btn.classList.toggle('active', btn.dataset.filter === 'all');
}); });
applyFilter('all'); applyFilter('all');
} catch (err) { } catch (err) {
tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3"> tbody.innerHTML = `<tr><td colspan="5" class="text-center text-danger py-3"><i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}</td></tr>`;
<i class="bi bi-exclamation-triangle me-1"></i>${esc(err.message)}
</td></tr>`;
filterRow.style.display = 'none';
runSummary.style.display = 'none';
} }
} }
// ── Filters ─────────────────────────────────────
function applyFilter(filter) { function applyFilter(filter) {
const rows = document.querySelectorAll('#logsBody tr[data-status]'); const rows = document.querySelectorAll('#logsBody tr[data-status]');
let visible = 0; let visible = 0;
rows.forEach(row => { rows.forEach(row => {
const show = filter === 'all' || row.dataset.status === filter; const show = filter === 'all' || row.dataset.status === filter;
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visible++; if (show) visible++;
}); });
updateFilterCount(visible, rows.length, filter); updateFilterCount(visible, rows.length, filter);
} }
function updateFilterCount(visible, total, filter) { function updateFilterCount(visible, total, filter) {
const el = document.getElementById('filterCount'); const el = document.getElementById('filterCount');
if (!el) return; if (!el) return;
if (visible == null) { if (visible == null) { el.textContent = ''; return; }
el.textContent = ''; el.textContent = filter === 'all' ? `${total} comenzi` : `${visible} din ${total} comenzi`;
return;
}
if (filter === 'all') {
el.textContent = `${total} comenzi`;
} else {
el.textContent = `${visible} din ${total} comenzi`;
}
} }
// ── Init ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadRuns(); loadRuns();
// Dropdown change // Dropdown change
document.getElementById('runSelector').addEventListener('change', function () { document.getElementById('runSelector').addEventListener('change', function() {
const runId = this.value; selectRun(this.value);
if (!runId) {
document.getElementById('logsBody').innerHTML = `<tr id="emptyState">
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
Selecteaza un sync run din lista de sus
</td>
</tr>`;
document.getElementById('filterRow').style.display = 'none';
document.getElementById('runSummary').style.display = 'none';
return;
}
loadRunLog(runId);
}); });
// Filter buttons // Filter buttons
document.querySelectorAll('[data-filter]').forEach(btn => { document.querySelectorAll('[data-filter]').forEach(btn => {
btn.addEventListener('click', function () { btn.addEventListener('click', function() {
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active')); document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
this.classList.add('active'); this.classList.add('active');
applyFilter(this.dataset.filter); applyFilter(this.dataset.filter);
}); });
}); });
// Auto-select run from URL or server
const preselected = document.getElementById('preselectedRun');
const urlParams = new URLSearchParams(window.location.search);
const runFromUrl = urlParams.get('run') || (preselected ? preselected.value : '');
if (runFromUrl) {
selectRun(runFromUrl);
}
}); });

View File

@@ -20,11 +20,6 @@
<i class="bi bi-speedometer2"></i> Dashboard <i class="bi bi-speedometer2"></i> Dashboard
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% block nav_sync %}{% endblock %}" href="/sync">
<i class="bi bi-arrow-repeat"></i> Import Comenzi
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings"> <a class="nav-link {% block nav_mappings %}{% endblock %}" href="/mappings">
<i class="bi bi-link-45deg"></i> Mapari SKU <i class="bi bi-link-45deg"></i> Mapari SKU

View File

@@ -105,6 +105,11 @@
<small class="text-muted" id="syncProgressText"></small> <small class="text-muted" id="syncProgressText"></small>
</div> </div>
</div> </div>
<div class="mt-2 d-none" id="syncStartedBanner">
<div class="alert alert-info alert-sm py-1 px-2 mb-0 d-inline-block">
<small><i class="bi bi-broadcast"></i> Sync pornit — <a href="#" id="syncRunLink">vezi progresul live</a></small>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -15,85 +15,125 @@
</div> </div>
</div> </div>
<!-- Filter buttons --> <!-- Sync Runs Table (always visible) -->
<div class="mb-3" id="filterRow" style="display:none;"> <div class="card mb-4">
<div class="btn-group btn-group-sm" role="group"> <div class="card-header d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-outline-secondary active" data-filter="all"> <span>Sync Runs</span>
<i class="bi bi-list-ul"></i> Toate <div id="runsTablePagination" class="d-flex align-items-center gap-2"></div>
</button>
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
<i class="bi bi-check-circle"></i> Importate
</button>
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
<i class="bi bi-skip-forward"></i> Fara Mapare
</button>
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
<i class="bi bi-x-circle"></i> Erori
</button>
</div> </div>
<small class="text-muted ms-3" id="filterCount"></small>
</div>
<!-- Run summary bar -->
<div class="row g-3 mb-3" id="runSummary" style="display:none;">
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
<div class="stat-label">Total</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
<div class="stat-label">Importate</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
<div class="stat-label">Omise</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
<div class="stat-label">Erori</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
<div class="stat-label">Durata</div>
</div>
</div>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0" id="logsTable"> <table class="table table-hover mb-0">
<thead> <thead>
<tr> <tr>
<th style="width:140px;">Nr. Comanda</th> <th>Data</th>
<th>Client</th> <th>Status</th>
<th style="width:100px;" class="text-center">Nr. Articole</th> <th>Total</th>
<th style="width:120px;">Status</th> <th>OK</th>
<th>Eroare / Detalii</th> <th>Fara mapare</th>
<th>Erori</th>
<th>Durata</th>
</tr> </tr>
</thead> </thead>
<tbody id="logsBody"> <tbody id="runsTableBody">
<tr id="emptyState"> <tr><td colspan="7" class="text-center text-muted py-3">Se incarca...</td></tr>
<td colspan="5" class="text-center text-muted py-5">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-muted opacity-50"></i>
Selecteaza un sync run din lista de sus
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- Run Detail Section (shown when run selected or live sync) -->
<div id="runDetailSection" style="display:none;">
<!-- Run Summary Bar -->
<div class="row g-3 mb-3" id="runSummary">
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-primary" id="sum-total" style="font-size:1.25rem;">-</div>
<div class="stat-label">Total</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-success" id="sum-imported" style="font-size:1.25rem;">-</div>
<div class="stat-label">Importate</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-warning" id="sum-skipped" style="font-size:1.25rem;">-</div>
<div class="stat-label">Omise</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-danger" id="sum-errors" style="font-size:1.25rem;">-</div>
<div class="stat-label">Erori</div>
</div>
</div>
<div class="col-auto">
<div class="card stat-card px-3 py-2">
<div class="stat-value text-secondary" id="sum-duration" style="font-size:1.25rem;">-</div>
<div class="stat-label">Durata</div>
</div>
</div>
</div>
<!-- Live Feed (visible only during active sync) -->
<div class="card mb-3" id="liveFeedCard" style="display:none;">
<div class="card-header">
<i class="bi bi-broadcast"></i> Live Feed
<span class="badge bg-danger ms-2 live-pulse">LIVE</span>
</div>
<div class="card-body p-0">
<div class="live-feed" id="liveFeed"></div>
</div>
</div>
<!-- Filter buttons -->
<div class="mb-3" id="filterRow">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="all">
<i class="bi bi-list-ul"></i> Toate
</button>
<button type="button" class="btn btn-outline-success" data-filter="IMPORTED">
<i class="bi bi-check-circle"></i> Importate
</button>
<button type="button" class="btn btn-outline-warning" data-filter="SKIPPED">
<i class="bi bi-skip-forward"></i> Fara Mapare
</button>
<button type="button" class="btn btn-outline-danger" data-filter="ERROR">
<i class="bi bi-x-circle"></i> Erori
</button>
</div>
<small class="text-muted ms-3" id="filterCount"></small>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="logsTable">
<thead>
<tr>
<th style="width:140px;">Nr. Comanda</th>
<th>Client</th>
<th style="width:100px;" class="text-center">Nr. Articole</th>
<th style="width:120px;">Status</th>
<th>Eroare / Detalii</th>
</tr>
</thead>
<tbody id="logsBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Hidden field for pre-selected run from URL/server -->
<input type="hidden" id="preselectedRun" value="{{ selected_run }}">
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@@ -1,158 +0,0 @@
{% extends "base.html" %}
{% block title %}Sync Run - GoMag Import{% endblock %}
{% block nav_sync %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="/" class="text-decoration-none text-muted"><i class="bi bi-arrow-left"></i> Dashboard</a>
<h4 class="mb-0 mt-1">Sync Run <small class="text-muted" id="runId">{{ run_id }}</small></h4>
</div>
<span class="badge bg-secondary fs-6" id="runStatusBadge">-</span>
</div>
<!-- Run summary -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value" id="runTotal">-</div>
<div class="stat-label">Total Comenzi</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-success" id="runImported">-</div>
<div class="stat-label">Imported</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-warning" id="runSkipped">-</div>
<div class="stat-label">Skipped</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<div class="stat-value text-danger" id="runErrors">-</div>
<div class="stat-label">Errors</div>
</div>
</div>
</div>
<div class="mb-3">
<small class="text-muted" id="runTiming"></small>
</div>
<!-- Orders table -->
<div class="card">
<div class="card-header">Comenzi</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Nr Comanda</th>
<th>Data</th>
<th>Client</th>
<th>Articole</th>
<th>Status</th>
<th>Detalii</th>
</tr>
</thead>
<tbody id="ordersBody">
<tr><td colspan="7" class="text-center text-muted py-4">Se incarca...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const RUN_ID = '{{ run_id }}';
document.addEventListener('DOMContentLoaded', loadRunDetail);
async function loadRunDetail() {
try {
const res = await fetch(`/api/sync/run/${RUN_ID}`);
const data = await res.json();
if (data.error) {
document.getElementById('ordersBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">${data.error}</td></tr>`;
return;
}
const run = data.run;
// Update summary
document.getElementById('runTotal').textContent = run.total_orders || 0;
document.getElementById('runImported').textContent = run.imported || 0;
document.getElementById('runSkipped').textContent = run.skipped || 0;
document.getElementById('runErrors').textContent = run.errors || 0;
const badge = document.getElementById('runStatusBadge');
badge.textContent = run.status;
badge.className = 'badge fs-6 ' + (run.status === 'completed' ? 'bg-success' : run.status === 'running' ? 'bg-primary' : 'bg-danger');
// Timing
if (run.started_at) {
let timing = 'Start: ' + new Date(run.started_at).toLocaleString('ro-RO');
if (run.finished_at) {
const sec = Math.round((new Date(run.finished_at) - new Date(run.started_at)) / 1000);
timing += ` | Durata: ${sec < 60 ? sec + 's' : Math.floor(sec/60) + 'm ' + (sec%60) + 's'}`;
}
document.getElementById('runTiming').textContent = timing;
}
// Orders table
const orders = data.orders || [];
if (orders.length === 0) {
document.getElementById('ordersBody').innerHTML =
'<tr><td colspan="7" class="text-center text-muted py-4">Nicio comanda</td></tr>';
return;
}
document.getElementById('ordersBody').innerHTML = orders.map((o, i) => {
const statusClass = o.status === 'IMPORTED' ? 'badge-imported' : o.status === 'SKIPPED' ? 'badge-skipped' : 'badge-error';
let details = '';
if (o.status === 'IMPORTED' && o.id_comanda) {
details = `<small class="text-success">ID: ${o.id_comanda}</small>`;
} else if (o.status === 'SKIPPED' && o.missing_skus) {
try {
const skus = JSON.parse(o.missing_skus);
details = `<small class="text-warning">SKU lipsa: ${skus.map(s => '<code>' + esc(s) + '</code>').join(', ')}</small>`;
} catch(e) {
details = `<small class="text-warning">${esc(o.missing_skus)}</small>`;
}
} else if (o.status === 'ERROR' && o.error_message) {
details = `<small class="text-danger">${esc(o.error_message).substring(0, 100)}</small>`;
}
return `<tr>
<td>${i + 1}</td>
<td><strong>${esc(o.order_number)}</strong></td>
<td><small>${o.order_date ? o.order_date.substring(0, 10) : '-'}</small></td>
<td>${esc(o.customer_name)}</td>
<td>${o.items_count || '-'}</td>
<td><span class="badge ${statusClass}">${o.status}</span></td>
<td>${details}</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('ordersBody').innerHTML =
`<tr><td colspan="7" class="text-center text-danger">Eroare: ${err.message}</td></tr>`;
}
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
{% endblock %}