Add live progress for historical Purchase and Sales sync
This commit is contained in:
@@ -4,7 +4,7 @@ import json
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.db.common import CommonSessionLocal
|
from app.core.db.common import CommonSessionLocal
|
||||||
@@ -14,6 +14,7 @@ from app.modules.accounting.historical_learning_models import AccountingHistoric
|
|||||||
from app.modules.accounting.historical_learning_service import active_natures, classifiable_ledgers, evidence_rows, ingest_completed_run, ledger_mappings, party_suggestions, remove_ledger_mapping, save_ledger_mapping
|
from app.modules.accounting.historical_learning_service import active_natures, classifiable_ledgers, evidence_rows, ingest_completed_run, ledger_mappings, party_suggestions, remove_ledger_mapping, save_ledger_mapping
|
||||||
from app.modules.accounting.ui import _accounting_storage_payload, _find_visible_client, _require_partner, _visible_clients
|
from app.modules.accounting.ui import _accounting_storage_payload, _find_visible_client, _require_partner, _visible_clients
|
||||||
from app.modules.accounting.historical_sync_support import eligible_tally_workstations, resolve_workstation_company, workstation_companies
|
from app.modules.accounting.historical_sync_support import eligible_tally_workstations, resolve_workstation_company, workstation_companies
|
||||||
|
from app.modules.accounting.historical_progress import historical_run_status
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.core.rbac.permission_guard import require_permission
|
from app.modules.core.rbac.permission_guard import require_permission
|
||||||
from app.modules.documents.agent_jobs import enqueue_agent_job
|
from app.modules.documents.agent_jobs import enqueue_agent_job
|
||||||
@@ -60,7 +61,7 @@ def historical_learning(request: Request, client_id: int | None=None, collected:
|
|||||||
return templates.TemplateResponse("modules/accounting/templates/accounting/historical_learning.html", {
|
return templates.TemplateResponse("modules/accounting/templates/accounting/historical_learning.html", {
|
||||||
"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request),
|
"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request),
|
||||||
"title":"Historical Tally Learning","clients":clients,"selected_client":selected,"workstations":workstations,"workstation_companies":{w.id:workstation_companies(w) for w in workstations},
|
"title":"Historical Tally Learning","clients":clients,"selected_client":selected,"workstations":workstations,"workstation_companies":{w.id:workstation_companies(w) for w in workstations},
|
||||||
"runs":runs,"evidence":evid,"classifiable_ledgers":ledgers,"suggestions":suggestions,"mappings":mappings,"natures":active_natures(db,scope.tenant_id),
|
"runs":runs,"latest_run":runs[0] if runs else None,"evidence":evid,"classifiable_ledgers":ledgers,"suggestions":suggestions,"mappings":mappings,"natures":active_natures(db,scope.tenant_id),
|
||||||
"date_from":df,"date_to":dt,"collected":bool(collected),"mapped":bool(mapped),"error":error,
|
"date_from":df,"date_to":dt,"collected":bool(collected),"mapped":bool(mapped),"error":error,
|
||||||
})
|
})
|
||||||
finally: db.close()
|
finally: db.close()
|
||||||
@@ -128,3 +129,30 @@ def unmap_ledger(request: Request, client_id: int=Form(...), mapping_id: int=For
|
|||||||
remove_ledger_mapping(db,tenant_id=scope.tenant_id,client_id=client.id,mapping_id=mapping_id)
|
remove_ledger_mapping(db,tenant_id=scope.tenant_id,client_id=client.id,mapping_id=mapping_id)
|
||||||
return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}",status_code=303)
|
return RedirectResponse(url=f"/tools/accounting/historical-learning?client_id={client_id}",status_code=303)
|
||||||
finally: db.close()
|
finally: db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/run/{run_id}/status")
|
||||||
|
def run_status(request: Request, run_id: int):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, response = _require_partner(request, db, "accounting.learning.view")
|
||||||
|
if response:
|
||||||
|
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
|
||||||
|
|
||||||
|
clients, scope = _visible_clients(db, request, user)
|
||||||
|
visible_client_ids = {int(c.id) for c in clients}
|
||||||
|
|
||||||
|
run = db.get(AccountingHistoricalLearningRun, int(run_id))
|
||||||
|
if (
|
||||||
|
not run
|
||||||
|
or int(run.tenant_id) != int(scope.tenant_id)
|
||||||
|
or int(run.client_id) not in visible_client_ids
|
||||||
|
):
|
||||||
|
return JSONResponse({"ok": False, "error": "Historical Purchase run not found."}, status_code=404)
|
||||||
|
|
||||||
|
if run.status not in {"completed", "failed"}:
|
||||||
|
ingest_completed_run(db, run)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, **historical_run_status(db, run)})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value):
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_seconds(started):
|
||||||
|
if not started:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
now = _utcnow()
|
||||||
|
value = started
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return max(0, int((now - value).total_seconds()))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def historical_run_status(db, run):
|
||||||
|
job = db.get(ERPAgentJob, run.agent_job_id) if run and run.agent_job_id else None
|
||||||
|
workstation = db.get(ERPWorkstationAgent, run.workstation_agent_id) if run and run.workstation_agent_id else None
|
||||||
|
|
||||||
|
job_status = str(job.status if job else run.status or "queued").lower()
|
||||||
|
run_status = str(run.status or job_status).lower()
|
||||||
|
|
||||||
|
if run_status == "completed":
|
||||||
|
stage = "completed"
|
||||||
|
label = "Completed"
|
||||||
|
terminal = True
|
||||||
|
ok = True
|
||||||
|
elif run_status == "failed":
|
||||||
|
stage = "failed"
|
||||||
|
label = "Failed"
|
||||||
|
terminal = True
|
||||||
|
ok = False
|
||||||
|
elif job_status == "claimed":
|
||||||
|
stage = "processing"
|
||||||
|
label = "Processing on workstation"
|
||||||
|
terminal = False
|
||||||
|
ok = None
|
||||||
|
elif job_status == "succeeded":
|
||||||
|
stage = "ingesting"
|
||||||
|
label = "Result received; updating ERP evidence"
|
||||||
|
terminal = False
|
||||||
|
ok = None
|
||||||
|
elif job_status == "failed":
|
||||||
|
stage = "failed"
|
||||||
|
label = "Failed"
|
||||||
|
terminal = True
|
||||||
|
ok = False
|
||||||
|
elif job_status == "cancelled":
|
||||||
|
stage = "failed"
|
||||||
|
label = "Cancelled"
|
||||||
|
terminal = True
|
||||||
|
ok = False
|
||||||
|
else:
|
||||||
|
stage = "queued"
|
||||||
|
label = "Queued for workstation"
|
||||||
|
terminal = False
|
||||||
|
ok = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"run_id": int(run.id),
|
||||||
|
"job_id": int(job.id) if job else None,
|
||||||
|
"status": run_status,
|
||||||
|
"job_status": job_status,
|
||||||
|
"stage": stage,
|
||||||
|
"label": label,
|
||||||
|
"terminal": terminal,
|
||||||
|
"ok": ok,
|
||||||
|
"workstation": (
|
||||||
|
str(getattr(workstation, "machine_name", "") or "").strip()
|
||||||
|
or str(getattr(workstation, "agent_instance_id", "") or "").strip()
|
||||||
|
or "ERP Local Agent"
|
||||||
|
) if workstation else "ERP Local Agent",
|
||||||
|
"company_name": str(run.company_name or run.tally_guid or ""),
|
||||||
|
"date_from": str(run.date_from or ""),
|
||||||
|
"date_to": str(run.date_to or ""),
|
||||||
|
"evidence_rows": int(run.evidence_rows or 0),
|
||||||
|
"attempts": int(job.attempts or 0) if job else 0,
|
||||||
|
"max_attempts": int(job.max_attempts or 0) if job else 0,
|
||||||
|
"error": str(run.error_message or (job.last_error if job else "") or ""),
|
||||||
|
"created_at": _iso(run.created_at_utc),
|
||||||
|
"claimed_at": _iso(job.claimed_at_utc) if job else None,
|
||||||
|
"completed_at": _iso(run.completed_at_utc or (job.completed_at_utc if job else None)),
|
||||||
|
"elapsed_seconds": _elapsed_seconds(run.created_at_utc),
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ from datetime import date, timedelta
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.db.common import CommonSessionLocal
|
from app.core.db.common import CommonSessionLocal
|
||||||
@@ -20,6 +20,7 @@ from app.modules.accounting.sales_learning_service import (
|
|||||||
)
|
)
|
||||||
from app.modules.accounting.ui import _accounting_storage_payload, _find_visible_client, _require_partner, _visible_clients
|
from app.modules.accounting.ui import _accounting_storage_payload, _find_visible_client, _require_partner, _visible_clients
|
||||||
from app.modules.accounting.historical_sync_support import eligible_tally_workstations, resolve_workstation_company, workstation_companies
|
from app.modules.accounting.historical_sync_support import eligible_tally_workstations, resolve_workstation_company, workstation_companies
|
||||||
|
from app.modules.accounting.historical_progress import historical_run_status
|
||||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||||
from app.modules.documents.agent_jobs import enqueue_agent_job
|
from app.modules.documents.agent_jobs import enqueue_agent_job
|
||||||
from app.modules.documents.models import ERPWorkstationAgent
|
from app.modules.documents.models import ERPWorkstationAgent
|
||||||
@@ -104,6 +105,7 @@ def page(
|
|||||||
"workstations": workstations,
|
"workstations": workstations,
|
||||||
"workstation_companies": {w.id: workstation_companies(w) for w in workstations},
|
"workstation_companies": {w.id: workstation_companies(w) for w in workstations},
|
||||||
"runs": runs,
|
"runs": runs,
|
||||||
|
"latest_run": runs[0] if runs else None,
|
||||||
"message": message,
|
"message": message,
|
||||||
"error": error,
|
"error": error,
|
||||||
},
|
},
|
||||||
@@ -191,3 +193,30 @@ def collect(
|
|||||||
return _go(client_id, error=str(exc))
|
return _go(client_id, error=str(exc))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/run/{run_id}/status")
|
||||||
|
def run_status(request: Request, run_id: int):
|
||||||
|
db = CommonSessionLocal()
|
||||||
|
try:
|
||||||
|
user, denied = _require_partner(request, db, "accounting.learning.view")
|
||||||
|
if denied:
|
||||||
|
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
|
||||||
|
|
||||||
|
clients, scope = _visible_clients(db, request, user)
|
||||||
|
visible_client_ids = {int(c.id) for c in clients}
|
||||||
|
|
||||||
|
run = db.get(AccountingSalesHistoricalRun, int(run_id))
|
||||||
|
if (
|
||||||
|
not run
|
||||||
|
or int(run.tenant_id) != int(scope.tenant_id)
|
||||||
|
or int(run.client_id) not in visible_client_ids
|
||||||
|
):
|
||||||
|
return JSONResponse({"ok": False, "error": "Historical Sales run not found."}, status_code=404)
|
||||||
|
|
||||||
|
if run.status not in {"completed", "failed"}:
|
||||||
|
ingest_completed_sales_run(db, run)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, **historical_run_status(db, run)})
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -17,6 +17,25 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if selected_client %}
|
{% if selected_client %}
|
||||||
|
{% if latest_run %}
|
||||||
|
<section id="historicalRunProgress"
|
||||||
|
data-status-url="/tools/accounting/historical-learning/run/{{ latest_run.id }}/status"
|
||||||
|
class="rounded-2xl border border-blue-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-blue-700">Latest Historical Purchase Run</div>
|
||||||
|
<div id="runProgressLabel" class="mt-1 text-lg font-semibold text-slate-900">{{ latest_run.status|replace('_',' ')|title }}</div>
|
||||||
|
<div id="runProgressDetail" class="mt-1 text-sm text-slate-500">{{ latest_run.company_name or latest_run.tally_guid }}</div>
|
||||||
|
</div>
|
||||||
|
<div id="runProgressMeta" class="text-right text-xs text-slate-500">Run #{{ latest_run.id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div id="runProgressBar" class="h-full w-1/3 rounded-full bg-blue-600 transition-all duration-500"></div>
|
||||||
|
</div>
|
||||||
|
<div id="runProgressError" class="mt-3 hidden rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"></div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Collect Historical Purchase Evidence</h2>
|
<h2 class="text-lg font-semibold text-slate-900">Collect Historical Purchase Evidence</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Select the connected ERP Local Agent workstation and currently open mapped Tally company. The agent refreshes the selected period read-only before returning neutral ledger-history facts; classification stays on the ERP server.</p>
|
<p class="mt-1 text-sm text-slate-500">Select the connected ERP Local Agent workstation and currently open mapped Tally company. The agent refreshes the selected period read-only before returning neutral ledger-history facts; classification stays on the ERP server.</p>
|
||||||
@@ -59,4 +78,80 @@
|
|||||||
<script>
|
<script>
|
||||||
const s=document.getElementById('wsCompany'); if(s){s.addEventListener('change',()=>{const p=s.value.split('|');document.getElementById('workstationId').value=p[0]||'';document.getElementById('tallyGuid').value=p[1]||'';document.getElementById('companyName').value=p.slice(2).join('|')||'';});}
|
const s=document.getElementById('wsCompany'); if(s){s.addEventListener('change',()=>{const p=s.value.split('|');document.getElementById('workstationId').value=p[0]||'';document.getElementById('tallyGuid').value=p[1]||'';document.getElementById('companyName').value=p.slice(2).join('|')||'';});}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const box = document.getElementById("historicalRunProgress");
|
||||||
|
if (!box) return;
|
||||||
|
const url = box.dataset.statusUrl;
|
||||||
|
const label = document.getElementById("runProgressLabel");
|
||||||
|
const detail = document.getElementById("runProgressDetail");
|
||||||
|
const meta = document.getElementById("runProgressMeta");
|
||||||
|
const bar = document.getElementById("runProgressBar");
|
||||||
|
const errorBox = document.getElementById("runProgressError");
|
||||||
|
let completedReloaded = false;
|
||||||
|
|
||||||
|
function humanSeconds(seconds) {
|
||||||
|
seconds = Number(seconds || 0);
|
||||||
|
if (seconds < 60) return seconds + " sec";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return mins + " min " + secs + " sec";
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(data) {
|
||||||
|
label.textContent = data.label || data.status || "Working";
|
||||||
|
detail.textContent = [
|
||||||
|
data.workstation,
|
||||||
|
data.company_name,
|
||||||
|
data.date_from && data.date_to ? (data.date_from + " → " + data.date_to) : ""
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
meta.textContent =
|
||||||
|
"Run #" + data.run_id +
|
||||||
|
" · attempt " + (data.attempts || 0) + "/" + (data.max_attempts || 0) +
|
||||||
|
" · elapsed " + humanSeconds(data.elapsed_seconds);
|
||||||
|
|
||||||
|
if (data.stage === "queued") {
|
||||||
|
bar.className = "h-full w-1/4 rounded-full bg-blue-500 animate-pulse transition-all duration-500";
|
||||||
|
} else if (data.stage === "processing") {
|
||||||
|
bar.className = "h-full w-2/3 rounded-full bg-blue-600 animate-pulse transition-all duration-500";
|
||||||
|
} else if (data.stage === "ingesting") {
|
||||||
|
bar.className = "h-full w-5/6 rounded-full bg-indigo-600 animate-pulse transition-all duration-500";
|
||||||
|
} else if (data.stage === "completed") {
|
||||||
|
bar.className = "h-full w-full rounded-full bg-emerald-600 transition-all duration-500";
|
||||||
|
label.textContent = "Completed · " + (data.evidence_rows || 0) + " evidence row(s)";
|
||||||
|
} else if (data.stage === "failed") {
|
||||||
|
bar.className = "h-full w-full rounded-full bg-red-600 transition-all duration-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errorBox.textContent = data.error;
|
||||||
|
errorBox.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
errorBox.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.terminal && !completedReloaded) {
|
||||||
|
completedReloaded = true;
|
||||||
|
window.setTimeout(function () { window.location.reload(); }, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {headers: {"Accept": "application/json"}, cache: "no-store"});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.ok) paint(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Keep the last visible state; next poll will retry.
|
||||||
|
}
|
||||||
|
if (!completedReloaded) window.setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,6 +32,26 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if latest_run %}
|
||||||
|
<section id="historicalRunProgress"
|
||||||
|
data-status-url="/tools/accounting/sales-learning/run/{{ latest_run.id }}/status"
|
||||||
|
data-run-status="{{ latest_run.status }}"
|
||||||
|
class="rounded-2xl border border-blue-200 bg-white p-5 shadow-soft">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-blue-700">Latest Historical Sales Run</div>
|
||||||
|
<div id="runProgressLabel" class="mt-1 text-lg font-semibold text-slate-900">{{ latest_run.status|replace('_',' ')|title }}</div>
|
||||||
|
<div id="runProgressDetail" class="mt-1 text-sm text-slate-500">{{ latest_run.company_name or latest_run.tally_guid }}</div>
|
||||||
|
</div>
|
||||||
|
<div id="runProgressMeta" class="text-right text-xs text-slate-500">Run #{{ latest_run.id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div id="runProgressBar" class="h-full w-1/3 rounded-full bg-blue-600 transition-all duration-500"></div>
|
||||||
|
</div>
|
||||||
|
<div id="runProgressError" class="mt-3 hidden rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"></div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h2 class="font-semibold">Collect Historical Sales Evidence from Tally</h2>
|
<h2 class="font-semibold">Collect Historical Sales Evidence from Tally</h2>
|
||||||
<p class="mt-1 text-sm text-slate-500">Read-only. The selected workstation refreshes Sales vouchers from the currently open mapped Tally company for the chosen period, then stores neutral historical evidence. It does not create or alter Tally vouchers.</p>
|
<p class="mt-1 text-sm text-slate-500">Read-only. The selected workstation refreshes Sales vouchers from the currently open mapped Tally company for the chosen period, then stores neutral historical evidence. It does not create or alter Tally vouchers.</p>
|
||||||
@@ -109,4 +129,80 @@
|
|||||||
syncSelection();
|
syncSelection();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const box = document.getElementById("historicalRunProgress");
|
||||||
|
if (!box) return;
|
||||||
|
const url = box.dataset.statusUrl;
|
||||||
|
const label = document.getElementById("runProgressLabel");
|
||||||
|
const detail = document.getElementById("runProgressDetail");
|
||||||
|
const meta = document.getElementById("runProgressMeta");
|
||||||
|
const bar = document.getElementById("runProgressBar");
|
||||||
|
const errorBox = document.getElementById("runProgressError");
|
||||||
|
let completedReloaded = false;
|
||||||
|
|
||||||
|
function humanSeconds(seconds) {
|
||||||
|
seconds = Number(seconds || 0);
|
||||||
|
if (seconds < 60) return seconds + " sec";
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return mins + " min " + secs + " sec";
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(data) {
|
||||||
|
label.textContent = data.label || data.status || "Working";
|
||||||
|
detail.textContent = [
|
||||||
|
data.workstation,
|
||||||
|
data.company_name,
|
||||||
|
data.date_from && data.date_to ? (data.date_from + " → " + data.date_to) : ""
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
meta.textContent =
|
||||||
|
"Run #" + data.run_id +
|
||||||
|
" · attempt " + (data.attempts || 0) + "/" + (data.max_attempts || 0) +
|
||||||
|
" · elapsed " + humanSeconds(data.elapsed_seconds);
|
||||||
|
|
||||||
|
if (data.stage === "queued") {
|
||||||
|
bar.className = "h-full w-1/4 rounded-full bg-blue-500 animate-pulse transition-all duration-500";
|
||||||
|
} else if (data.stage === "processing") {
|
||||||
|
bar.className = "h-full w-2/3 rounded-full bg-blue-600 animate-pulse transition-all duration-500";
|
||||||
|
} else if (data.stage === "ingesting") {
|
||||||
|
bar.className = "h-full w-5/6 rounded-full bg-indigo-600 animate-pulse transition-all duration-500";
|
||||||
|
} else if (data.stage === "completed") {
|
||||||
|
bar.className = "h-full w-full rounded-full bg-emerald-600 transition-all duration-500";
|
||||||
|
label.textContent = "Completed · " + (data.evidence_rows || 0) + " evidence row(s)";
|
||||||
|
} else if (data.stage === "failed") {
|
||||||
|
bar.className = "h-full w-full rounded-full bg-red-600 transition-all duration-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errorBox.textContent = data.error;
|
||||||
|
errorBox.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
errorBox.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.terminal && !completedReloaded) {
|
||||||
|
completedReloaded = true;
|
||||||
|
window.setTimeout(function () { window.location.reload(); }, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {headers: {"Accept": "application/json"}, cache: "no-store"});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.ok) paint(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Keep the last visible state; next poll will retry.
|
||||||
|
}
|
||||||
|
if (!completedReloaded) window.setTimeout(poll, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user