Harshit Ghosh Copilot commited on
Commit Β·
46811bc
1
Parent(s): 5105d0e
progress and cancel
Browse filesCo-authored-by: Copilot <copilot@github.com>
- app_new.py +47 -4
- auth_routes.py +3 -0
- static/css/pages.css +5 -0
- static/js/batch.js +45 -3
- templates/batch_progress.html +12 -3
app_new.py
CHANGED
|
@@ -1056,7 +1056,7 @@ def analyze():
|
|
| 1056 |
user_id=current_user.id,
|
| 1057 |
details=f"batch_id={batch_id}, files={len(dcm_paths)}",
|
| 1058 |
)
|
| 1059 |
-
return redirect(url_for("batch_progress", batch_id=batch_id))
|
| 1060 |
except Exception:
|
| 1061 |
logger.error("Celery unavailable; running synchronous fallback", exc_info=True)
|
| 1062 |
flash("Celery worker unavailable. Running batch synchronously; this may take a while.", "warning")
|
|
@@ -1097,7 +1097,7 @@ def analyze_directory():
|
|
| 1097 |
user_id=current_user.id,
|
| 1098 |
details=f"batch_id={batch_id}, files={len(dcm_paths)}",
|
| 1099 |
)
|
| 1100 |
-
return redirect(url_for("batch_progress", batch_id=batch_id))
|
| 1101 |
except Exception:
|
| 1102 |
logger.error("Celery unavailable; running synchronous directory scan", exc_info=True)
|
| 1103 |
flash("Celery worker unavailable. Running directory scan synchronously.", "warning")
|
|
@@ -1115,8 +1115,15 @@ def batch_progress(batch_id):
|
|
| 1115 |
batch = _get_batch_from_celery(batch_id)
|
| 1116 |
if not batch or batch.get("user_id") != current_user.id:
|
| 1117 |
abort(404)
|
| 1118 |
-
|
| 1119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
|
| 1121 |
@app.route("/batch/<batch_id>/status")
|
| 1122 |
@login_required
|
|
@@ -1127,6 +1134,26 @@ def batch_status(batch_id):
|
|
| 1127 |
return jsonify({"error": "Not found"}), 404
|
| 1128 |
return jsonify(batch)
|
| 1129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1130 |
def _get_batch_from_celery(batch_id: str) -> dict[str, Any] | None:
|
| 1131 |
"""Retrieve batch status from Celery task result backend."""
|
| 1132 |
# In a production system, we'd also validate user_id from the database
|
|
@@ -1155,6 +1182,22 @@ def _get_batch_from_celery(batch_id: str) -> dict[str, Any] | None:
|
|
| 1155 |
"error": None,
|
| 1156 |
"queue_size": queue_size,
|
| 1157 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1158 |
|
| 1159 |
# Build response matching _BATCHES format for frontend compatibility
|
| 1160 |
if result.state == "PROGRESS":
|
|
|
|
| 1056 |
user_id=current_user.id,
|
| 1057 |
details=f"batch_id={batch_id}, files={len(dcm_paths)}",
|
| 1058 |
)
|
| 1059 |
+
return redirect(url_for("batch_progress", batch_id=batch_id, total=len(dcm_paths)))
|
| 1060 |
except Exception:
|
| 1061 |
logger.error("Celery unavailable; running synchronous fallback", exc_info=True)
|
| 1062 |
flash("Celery worker unavailable. Running batch synchronously; this may take a while.", "warning")
|
|
|
|
| 1097 |
user_id=current_user.id,
|
| 1098 |
details=f"batch_id={batch_id}, files={len(dcm_paths)}",
|
| 1099 |
)
|
| 1100 |
+
return redirect(url_for("batch_progress", batch_id=batch_id, total=len(dcm_paths)))
|
| 1101 |
except Exception:
|
| 1102 |
logger.error("Celery unavailable; running synchronous directory scan", exc_info=True)
|
| 1103 |
flash("Celery worker unavailable. Running directory scan synchronously.", "warning")
|
|
|
|
| 1115 |
batch = _get_batch_from_celery(batch_id)
|
| 1116 |
if not batch or batch.get("user_id") != current_user.id:
|
| 1117 |
abort(404)
|
| 1118 |
+
expected_total = request.args.get("total", type=int)
|
| 1119 |
+
if expected_total and (batch.get("total") or 0) == 0:
|
| 1120 |
+
batch["total"] = expected_total
|
| 1121 |
+
return render_template(
|
| 1122 |
+
"batch_progress.html",
|
| 1123 |
+
batch=batch,
|
| 1124 |
+
batch_id=batch_id,
|
| 1125 |
+
expected_total=expected_total or 0,
|
| 1126 |
+
)
|
| 1127 |
|
| 1128 |
@app.route("/batch/<batch_id>/status")
|
| 1129 |
@login_required
|
|
|
|
| 1134 |
return jsonify({"error": "Not found"}), 404
|
| 1135 |
return jsonify(batch)
|
| 1136 |
|
| 1137 |
+
@app.route("/batch/<batch_id>/cancel", methods=["POST"])
|
| 1138 |
+
@login_required
|
| 1139 |
+
def cancel_batch(batch_id):
|
| 1140 |
+
"""Cancel a running batch task."""
|
| 1141 |
+
user_id = _extract_user_id_from_batch_id(batch_id)
|
| 1142 |
+
if user_id != current_user.id:
|
| 1143 |
+
abort(404)
|
| 1144 |
+
try:
|
| 1145 |
+
celery_app.control.revoke(batch_id, terminate=True, signal="SIGTERM")
|
| 1146 |
+
log_audit(
|
| 1147 |
+
"batch_canceled",
|
| 1148 |
+
user_id=current_user.id,
|
| 1149 |
+
details=f"batch_id={batch_id}",
|
| 1150 |
+
status="success",
|
| 1151 |
+
)
|
| 1152 |
+
return jsonify({"status": "canceled"})
|
| 1153 |
+
except Exception as exc:
|
| 1154 |
+
logger.error("Failed to cancel batch %s: %s", batch_id, exc, exc_info=True)
|
| 1155 |
+
return jsonify({"error": "Cancel failed"}), 500
|
| 1156 |
+
|
| 1157 |
def _get_batch_from_celery(batch_id: str) -> dict[str, Any] | None:
|
| 1158 |
"""Retrieve batch status from Celery task result backend."""
|
| 1159 |
# In a production system, we'd also validate user_id from the database
|
|
|
|
| 1182 |
"error": None,
|
| 1183 |
"queue_size": queue_size,
|
| 1184 |
}
|
| 1185 |
+
elif result.state == "REVOKED":
|
| 1186 |
+
return {
|
| 1187 |
+
"batch_id": batch_id,
|
| 1188 |
+
"user_id": user_id,
|
| 1189 |
+
"status": "canceled",
|
| 1190 |
+
"total": 0,
|
| 1191 |
+
"processed": 0,
|
| 1192 |
+
"succeeded": 0,
|
| 1193 |
+
"failed_ids": [],
|
| 1194 |
+
"image_ids": [],
|
| 1195 |
+
"current_file": "",
|
| 1196 |
+
"started_at": None,
|
| 1197 |
+
"finished_at": None,
|
| 1198 |
+
"error": None,
|
| 1199 |
+
"queue_size": queue_size,
|
| 1200 |
+
}
|
| 1201 |
|
| 1202 |
# Build response matching _BATCHES format for frontend compatibility
|
| 1203 |
if result.state == "PROGRESS":
|
auth_routes.py
CHANGED
|
@@ -92,6 +92,9 @@ def _validate_otp(submitted_code: str, expected_purpose: str) -> tuple[bool, str
|
|
| 92 |
return False, "OTP purpose mismatch. Please request a new code.", None
|
| 93 |
|
| 94 |
expires_raw = payload.get("expires_at")
|
|
|
|
|
|
|
|
|
|
| 95 |
try:
|
| 96 |
expires_at = datetime.fromisoformat(expires_raw)
|
| 97 |
except Exception:
|
|
|
|
| 92 |
return False, "OTP purpose mismatch. Please request a new code.", None
|
| 93 |
|
| 94 |
expires_raw = payload.get("expires_at")
|
| 95 |
+
if not expires_raw:
|
| 96 |
+
_clear_otp()
|
| 97 |
+
return False, "OTP is invalid. Please request a new code.", None
|
| 98 |
try:
|
| 99 |
expires_at = datetime.fromisoformat(expires_raw)
|
| 100 |
except Exception:
|
static/css/pages.css
CHANGED
|
@@ -963,6 +963,11 @@
|
|
| 963 |
.batch-header {
|
| 964 |
margin-bottom: 20px;
|
| 965 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
.batch-header h1 {
|
| 967 |
font-size: 1.6rem;
|
| 968 |
font-weight: 800;
|
|
|
|
| 963 |
.batch-header {
|
| 964 |
margin-bottom: 20px;
|
| 965 |
}
|
| 966 |
+
.batch-header-actions {
|
| 967 |
+
margin-top: 14px;
|
| 968 |
+
display: flex;
|
| 969 |
+
justify-content: flex-end;
|
| 970 |
+
}
|
| 971 |
.batch-header h1 {
|
| 972 |
font-size: 1.6rem;
|
| 973 |
font-weight: 800;
|
static/js/batch.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
| 7 |
|
| 8 |
var statusUrl = page.dataset.statusUrl;
|
| 9 |
var pollMs = 1000;
|
|
|
|
|
|
|
| 10 |
|
| 11 |
var title = document.getElementById('batchTitle');
|
| 12 |
var subtitle = document.getElementById('batchSubtitle');
|
|
@@ -24,21 +26,51 @@
|
|
| 24 |
var doneSummary = document.getElementById('doneSummary');
|
| 25 |
var failPanel = document.getElementById('failPanel');
|
| 26 |
var failList = document.getElementById('failList');
|
|
|
|
| 27 |
var prevIds = [];
|
|
|
|
| 28 |
|
| 29 |
if (!statusUrl || !title || !subtitle || !fill || !pctLabel || !currentFile || !statTotal || !statProc || !statOK || !statFail || !feedPanel || !feedList || !donePanel || !doneSummary || !failPanel || !failList) {
|
| 30 |
return;
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
function poll() {
|
| 34 |
fetch(statusUrl)
|
| 35 |
.then(function (response) {
|
| 36 |
return response.json();
|
| 37 |
})
|
| 38 |
.then(function (data) {
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
statProc.textContent = data.processed;
|
| 43 |
statOK.textContent = data.succeeded;
|
| 44 |
statFail.textContent = data.failed_ids ? data.failed_ids.length : 0;
|
|
@@ -53,6 +85,10 @@
|
|
| 53 |
}
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
fill.style.width = pct + '%';
|
| 57 |
pctLabel.textContent = pct + '%';
|
| 58 |
currentFile.textContent = data.current_file ? 'Processing: ' + data.current_file : '';
|
|
@@ -75,12 +111,18 @@
|
|
| 75 |
});
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if (data.status === 'completed' || data.status === 'failed') {
|
| 79 |
title.textContent = 'Batch Complete';
|
| 80 |
subtitle.textContent = '';
|
| 81 |
donePanel.style.display = 'block';
|
| 82 |
var failCount = data.failed_ids ? data.failed_ids.length : 0;
|
| 83 |
-
doneSummary.textContent = data.succeeded + ' of ' +
|
| 84 |
|
| 85 |
if (data.failed_ids && data.failed_ids.length) {
|
| 86 |
failPanel.style.display = 'block';
|
|
|
|
| 7 |
|
| 8 |
var statusUrl = page.dataset.statusUrl;
|
| 9 |
var pollMs = 1000;
|
| 10 |
+
var expectedTotal = parseInt(page.dataset.expectedTotal || '0', 10) || 0;
|
| 11 |
+
var cancelUrl = page.dataset.cancelUrl;
|
| 12 |
|
| 13 |
var title = document.getElementById('batchTitle');
|
| 14 |
var subtitle = document.getElementById('batchSubtitle');
|
|
|
|
| 26 |
var doneSummary = document.getElementById('doneSummary');
|
| 27 |
var failPanel = document.getElementById('failPanel');
|
| 28 |
var failList = document.getElementById('failList');
|
| 29 |
+
var cancelBtn = document.getElementById('cancelBatch');
|
| 30 |
var prevIds = [];
|
| 31 |
+
var canceled = false;
|
| 32 |
|
| 33 |
if (!statusUrl || !title || !subtitle || !fill || !pctLabel || !currentFile || !statTotal || !statProc || !statOK || !statFail || !feedPanel || !feedList || !donePanel || !doneSummary || !failPanel || !failList) {
|
| 34 |
return;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
if (cancelBtn && cancelUrl) {
|
| 38 |
+
cancelBtn.addEventListener('click', function () {
|
| 39 |
+
if (!confirm('Cancel this batch? Any in-progress file may not complete.')) {
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
cancelBtn.disabled = true;
|
| 43 |
+
fetch(cancelUrl, { method: 'POST' })
|
| 44 |
+
.then(function (response) { return response.json(); })
|
| 45 |
+
.then(function (data) {
|
| 46 |
+
canceled = true;
|
| 47 |
+
title.textContent = 'Batch Canceled';
|
| 48 |
+
subtitle.textContent = 'The batch was canceled. You can start a new upload anytime.';
|
| 49 |
+
currentFile.textContent = '';
|
| 50 |
+
if (queueStatus) {
|
| 51 |
+
queueStatus.textContent = '';
|
| 52 |
+
}
|
| 53 |
+
})
|
| 54 |
+
.catch(function () {
|
| 55 |
+
cancelBtn.disabled = false;
|
| 56 |
+
});
|
| 57 |
+
});
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
function poll() {
|
| 61 |
fetch(statusUrl)
|
| 62 |
.then(function (response) {
|
| 63 |
return response.json();
|
| 64 |
})
|
| 65 |
.then(function (data) {
|
| 66 |
+
if (canceled) {
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
|
| 70 |
+
var total = data.total > 0 ? data.total : expectedTotal;
|
| 71 |
+
var pct = total > 0 ? Math.round(data.processed / total * 100) : 0;
|
| 72 |
+
|
| 73 |
+
statTotal.textContent = total;
|
| 74 |
statProc.textContent = data.processed;
|
| 75 |
statOK.textContent = data.succeeded;
|
| 76 |
statFail.textContent = data.failed_ids ? data.failed_ids.length : 0;
|
|
|
|
| 85 |
}
|
| 86 |
}
|
| 87 |
|
| 88 |
+
if (data.status === 'pending' && expectedTotal > 0) {
|
| 89 |
+
subtitle.textContent = 'Queued - waiting for a worker. Total files: ' + expectedTotal + '.';
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
fill.style.width = pct + '%';
|
| 93 |
pctLabel.textContent = pct + '%';
|
| 94 |
currentFile.textContent = data.current_file ? 'Processing: ' + data.current_file : '';
|
|
|
|
| 111 |
});
|
| 112 |
}
|
| 113 |
|
| 114 |
+
if (data.status === 'canceled') {
|
| 115 |
+
title.textContent = 'Batch Canceled';
|
| 116 |
+
subtitle.textContent = 'The batch was canceled.';
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
if (data.status === 'completed' || data.status === 'failed') {
|
| 121 |
title.textContent = 'Batch Complete';
|
| 122 |
subtitle.textContent = '';
|
| 123 |
donePanel.style.display = 'block';
|
| 124 |
var failCount = data.failed_ids ? data.failed_ids.length : 0;
|
| 125 |
+
doneSummary.textContent = data.succeeded + ' of ' + total + ' files processed successfully' + (failCount > 0 ? ', ' + failCount + ' failed' : '') + '.';
|
| 126 |
|
| 127 |
if (data.failed_ids && data.failed_ids.length) {
|
| 128 |
failPanel.style.display = 'block';
|
templates/batch_progress.html
CHANGED
|
@@ -3,7 +3,13 @@
|
|
| 3 |
{% block title %}Batch Processing β AI Medical Intelligence Pipeline{% endblock %}
|
| 4 |
|
| 5 |
{% block content %}
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
<section class="breadcrumb">
|
| 8 |
<a href="{{ url_for('home') }}">Home</a>
|
| 9 |
<span class="sep">/</span>
|
|
@@ -15,13 +21,16 @@
|
|
| 15 |
<section class="batch-header">
|
| 16 |
<h1 id="batchTitle">Processing Batch…</h1>
|
| 17 |
<p class="muted" id="batchSubtitle">
|
| 18 |
-
Analyzing {{
|
| 19 |
</p>
|
| 20 |
<p class="muted" id="queueStatus">
|
| 21 |
{% if batch.queue_size is not none %}
|
| 22 |
Queue size: {{ batch.queue_size }}
|
| 23 |
{% endif %}
|
| 24 |
</p>
|
|
|
|
|
|
|
|
|
|
| 25 |
</section>
|
| 26 |
|
| 27 |
<!-- ββ Progress bar ββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
|
@@ -29,7 +38,7 @@
|
|
| 29 |
<div class="batch-stats-row">
|
| 30 |
<div class="batch-stat">
|
| 31 |
<span class="batch-stat-label">Total</span>
|
| 32 |
-
<span class="batch-stat-value" id="statTotal">{{
|
| 33 |
</div>
|
| 34 |
<div class="batch-stat">
|
| 35 |
<span class="batch-stat-label">Processed</span>
|
|
|
|
| 3 |
{% block title %}Batch Processing β AI Medical Intelligence Pipeline{% endblock %}
|
| 4 |
|
| 5 |
{% block content %}
|
| 6 |
+
{% set display_total = batch.total if batch.total else (expected_total or 0) %}
|
| 7 |
+
<div class="batch-page"
|
| 8 |
+
data-batch-id="{{ batch_id }}"
|
| 9 |
+
data-status-url="{{ url_for('batch_status', batch_id=batch_id) }}"
|
| 10 |
+
data-cancel-url="{{ url_for('cancel_batch', batch_id=batch_id) }}"
|
| 11 |
+
data-reports-url="{{ url_for('reports') }}"
|
| 12 |
+
data-expected-total="{{ display_total }}">
|
| 13 |
<section class="breadcrumb">
|
| 14 |
<a href="{{ url_for('home') }}">Home</a>
|
| 15 |
<span class="sep">/</span>
|
|
|
|
| 21 |
<section class="batch-header">
|
| 22 |
<h1 id="batchTitle">Processing Batch…</h1>
|
| 23 |
<p class="muted" id="batchSubtitle">
|
| 24 |
+
Analyzing {{ display_total }} DICOM file{{ 's' if display_total != 1 }} β please keep this page open.
|
| 25 |
</p>
|
| 26 |
<p class="muted" id="queueStatus">
|
| 27 |
{% if batch.queue_size is not none %}
|
| 28 |
Queue size: {{ batch.queue_size }}
|
| 29 |
{% endif %}
|
| 30 |
</p>
|
| 31 |
+
<div class="batch-header-actions">
|
| 32 |
+
<button type="button" class="btn btn-outline" id="cancelBatch">Cancel Batch</button>
|
| 33 |
+
</div>
|
| 34 |
</section>
|
| 35 |
|
| 36 |
<!-- ββ Progress bar ββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
|
|
|
| 38 |
<div class="batch-stats-row">
|
| 39 |
<div class="batch-stat">
|
| 40 |
<span class="batch-stat-label">Total</span>
|
| 41 |
+
<span class="batch-stat-value" id="statTotal">{{ display_total }}</span>
|
| 42 |
</div>
|
| 43 |
<div class="batch-stat">
|
| 44 |
<span class="batch-stat-label">Processed</span>
|