Skip to content

Commit cdb014e

Browse files
committed
[feature] Add multicolor progress bar for mass upgrade operations #349
Closes #349
1 parent c142d19 commit cdb014e

4 files changed

Lines changed: 221 additions & 55 deletions

File tree

openwisp_firmware_upgrader/static/firmware-upgrader/css/batch-upgrade-operation.css

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,16 @@
200200
.batch-main-progress {
201201
display: flex;
202202
align-items: center;
203-
gap: 20px;
203+
flex-wrap: wrap;
204+
gap: 6px 20px;
204205
margin-left: 20px;
206+
min-width: 0;
205207
}
206208

207209
.batch-main-progress .upgrade-progress-bar {
208-
width: 300px;
210+
display: flex;
211+
width: 100%;
212+
max-width: 300px;
209213
height: 12px;
210214
background-color: #d9d9d9;
211215
border-radius: 4px;
@@ -215,10 +219,21 @@
215219

216220
.batch-main-progress .upgrade-progress-fill {
217221
height: 100%;
218-
border-radius: 2px;
219222
transition: width 0.5s ease;
220223
}
221224

225+
.batch-main-progress .upgrade-progress-fill:first-child {
226+
border-radius: 2px 0 0 2px;
227+
}
228+
229+
.batch-main-progress .upgrade-progress-fill:last-child {
230+
border-radius: 0 2px 2px 0;
231+
}
232+
233+
.batch-main-progress .upgrade-progress-fill:only-child {
234+
border-radius: 2px;
235+
}
236+
222237
.batch-main-progress .upgrade-progress-fill.idle {
223238
background-color: #6c757d;
224239
}
@@ -254,6 +269,43 @@
254269
white-space: nowrap;
255270
}
256271

272+
.batch-progress-legend {
273+
display: flex;
274+
flex-basis: 100%;
275+
flex-wrap: wrap;
276+
gap: 4px 12px;
277+
font-size: 12px;
278+
color: var(--body-quiet-color);
279+
}
280+
281+
.batch-progress-legend .legend-item {
282+
display: flex;
283+
align-items: center;
284+
gap: 4px;
285+
}
286+
287+
.batch-progress-legend .legend-dot {
288+
width: 10px;
289+
height: 10px;
290+
border-radius: 50%;
291+
}
292+
293+
.batch-progress-legend .legend-dot.success {
294+
background-color: #70bf2b;
295+
}
296+
297+
.batch-progress-legend .legend-dot.failed {
298+
background-color: #dc3545;
299+
}
300+
301+
.batch-progress-legend .legend-dot.aborted {
302+
background-color: #6c757d;
303+
}
304+
305+
.batch-progress-legend .legend-dot.cancelled {
306+
background-color: #8b8b8b;
307+
}
308+
257309
/* Adjustments for list filters */
258310
#main #content .left-arrow {
259311
left: -1.125rem;

openwisp_firmware_upgrader/static/firmware-upgrader/js/batch-upgrade-progress.js

Lines changed: 108 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -140,61 +140,127 @@ function updateBatchProgress(data) {
140140
let $ = django.jQuery;
141141
let mainProgressElement = $(".batch-main-progress");
142142
if (mainProgressElement.length > 0) {
143-
let progressPercentage =
144-
data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0;
143+
let total = data.total || 0;
144+
let completed = data.completed || 0;
145+
let progressPercentage = total > 0 ? Math.round((completed / total) * 100) : 0;
145146
let showPercentageText = true;
146-
let statusClass = FW_UPGRADE_CSS_CLASSES.IN_PROGRESS; // Safe default
147+
let progressHtml = "";
148+
let legendHtml = "";
147149

148-
if (data.status === FW_UPGRADE_STATUS.SUCCESS) {
149-
progressPercentage = 100;
150-
statusClass = FW_UPGRADE_CSS_CLASSES.COMPLETED_SUCCESSFULLY;
151-
showPercentageText = true;
152-
} else if (data.status === FW_UPGRADE_STATUS.CANCELLED) {
153-
progressPercentage = 100;
154-
statusClass = FW_UPGRADE_CSS_CLASSES.CANCELLED;
155-
showPercentageText = false;
156-
} else if (data.status === FW_UPGRADE_STATUS.FAILED) {
157-
let successfulOpsCount = $("#result_list tbody tr").filter(function () {
158-
let statusText = $(this).find(".status-cell .status-content").text().trim();
159-
return FW_STATUS_GROUPS.SUCCESS.has(statusText);
160-
}).length;
161-
// Also check individual operation containers for success
162-
if (successfulOpsCount === 0) {
163-
$("#result_list tbody tr").each(function () {
164-
let statusContainer = $(this).find(".upgrade-status-container");
165-
if (
166-
statusContainer.length &&
167-
statusContainer.find(".upgrade-progress-fill.success").length
168-
) {
169-
successfulOpsCount++;
170-
}
150+
let statusCountSum =
151+
(data.successful || 0) +
152+
(data.failed || 0) +
153+
(data.aborted || 0) +
154+
(data.cancelled || 0);
155+
if (data.successful !== undefined && total > 0 && statusCountSum > 0) {
156+
// Multicolor status which shows proportional segments per status
157+
let successful = data.successful || 0;
158+
let failed = data.failed || 0;
159+
let aborted = data.aborted || 0;
160+
let cancelled = data.cancelled || 0;
161+
let segments = [];
162+
if (successful > 0) {
163+
segments.push({
164+
cssClass: FW_UPGRADE_CSS_CLASSES.SUCCESS,
165+
count: successful,
166+
label: FW_UPGRADE_DISPLAY_STATUS.SUCCESS,
167+
});
168+
}
169+
if (failed > 0) {
170+
segments.push({
171+
cssClass: FW_UPGRADE_CSS_CLASSES.FAILED,
172+
count: failed,
173+
label: FW_UPGRADE_DISPLAY_STATUS.FAILED,
171174
});
172175
}
173-
if (successfulOpsCount > 0) {
174-
// Some operations succeeded - partial success (orange)
176+
if (aborted > 0) {
177+
segments.push({
178+
cssClass: FW_UPGRADE_CSS_CLASSES.ABORTED,
179+
count: aborted,
180+
label: FW_UPGRADE_DISPLAY_STATUS.ABORTED,
181+
});
182+
}
183+
if (cancelled > 0) {
184+
segments.push({
185+
cssClass: FW_UPGRADE_CSS_CLASSES.CANCELLED,
186+
count: cancelled,
187+
label: FW_UPGRADE_DISPLAY_STATUS.CANCELLED,
188+
});
189+
}
190+
let usedWidth = 0;
191+
let cumulativeCount = 0;
192+
let segmentsHtml = segments
193+
.map(function (seg) {
194+
cumulativeCount += seg.count;
195+
let cumulativeWidth = Math.round(
196+
(cumulativeCount / total) * progressPercentage,
197+
);
198+
let segmentWidth = Math.max(cumulativeWidth - usedWidth, 0);
199+
usedWidth = cumulativeWidth;
200+
return (
201+
'<div class="upgrade-progress-fill ' +
202+
escapeHtml(seg.cssClass) +
203+
'" style="width: ' +
204+
escapeHtml(String(segmentWidth)) +
205+
'%"></div>'
206+
);
207+
})
208+
.join("");
209+
progressHtml = '<div class="upgrade-progress-bar">' + segmentsHtml + "</div>";
210+
// legend building function
211+
if (segments.length > 0) {
212+
let legendItems = segments
213+
.map(function (seg) {
214+
return (
215+
'<span class="legend-item">' +
216+
'<span class="legend-dot ' +
217+
escapeHtml(seg.cssClass) +
218+
'"></span>' +
219+
escapeHtml(String(seg.count)) +
220+
" " +
221+
escapeHtml(seg.label) +
222+
"</span>"
223+
);
224+
})
225+
.join("");
226+
legendHtml = '<div class="batch-progress-legend">' + legendItems + "</div>";
227+
}
228+
if (
229+
data.status === FW_UPGRADE_STATUS.FAILED ||
230+
data.status === FW_UPGRADE_STATUS.CANCELLED
231+
) {
232+
showPercentageText = false;
233+
}
234+
} else {
235+
// Fallback: single-color will be rendered when per-status counts are not available
236+
let statusClass = FW_UPGRADE_CSS_CLASSES.IN_PROGRESS;
237+
if (data.status === FW_UPGRADE_STATUS.SUCCESS) {
175238
progressPercentage = 100;
176-
statusClass = FW_UPGRADE_CSS_CLASSES.PARTIAL_SUCCESS;
239+
statusClass = FW_UPGRADE_CSS_CLASSES.COMPLETED_SUCCESSFULLY;
240+
} else if (data.status === FW_UPGRADE_STATUS.CANCELLED) {
241+
progressPercentage = 100;
242+
statusClass = FW_UPGRADE_CSS_CLASSES.CANCELLED;
177243
showPercentageText = false;
178-
} else {
179-
// All operations failed - total failure (red)
244+
} else if (data.status === FW_UPGRADE_STATUS.FAILED) {
180245
progressPercentage = 100;
181246
statusClass = FW_UPGRADE_CSS_CLASSES.FAILED;
182247
showPercentageText = false;
183248
}
249+
progressHtml =
250+
'<div class="upgrade-progress-bar">' +
251+
'<div class="upgrade-progress-fill ' +
252+
escapeHtml(statusClass) +
253+
'" style="width: ' +
254+
escapeHtml(String(progressPercentage)) +
255+
'%"></div></div>';
184256
}
185-
let progressHtml = `
186-
<div class="upgrade-progress-bar">
187-
<div class="upgrade-progress-fill ${escapeHtml(statusClass)}"
188-
style="width: ${escapeHtml(String(progressPercentage))}%">
189-
</div>
190-
</div>
191-
`;
192257
if (showPercentageText) {
193-
progressHtml += `<span class="upgrade-progress-text">
194-
${escapeHtml(String(progressPercentage))}%
195-
</span>`;
258+
progressHtml +=
259+
'<span class="upgrade-progress-text">' +
260+
escapeHtml(String(progressPercentage)) +
261+
"%</span>";
196262
}
197-
mainProgressElement.html(progressHtml);
263+
mainProgressElement.html(progressHtml + legendHtml);
198264
}
199265
// Update completion information in the admin form if available
200266
if (data.total !== undefined && data.completed !== undefined) {

openwisp_firmware_upgrader/tests/test_websockets.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ async def test_batch_upgrade_progress_consumer_connection(self):
182182
"status": "in-progress",
183183
"completed": 0,
184184
"total": 2,
185+
"successful": 0,
186+
"failed": 0,
187+
"aborted": 0,
188+
"cancelled": 0,
185189
},
186190
},
187191
)
@@ -190,6 +194,19 @@ async def test_batch_upgrade_progress_consumer_connection(self):
190194
self.assertEqual(response["status"], "in-progress")
191195
self.assertEqual(response["completed"], 0)
192196
self.assertEqual(response["total"], 2)
197+
self.assertEqual(response["successful"], 0)
198+
self.assertEqual(response["failed"], 0)
199+
self.assertEqual(response["aborted"], 0)
200+
self.assertEqual(response["cancelled"], 0)
201+
202+
await communicator.send_json_to({"type": "request_current_state"})
203+
response = await communicator.receive_json_from()
204+
self.assertEqual(response["type"], "batch_state")
205+
self.assertEqual(response["batch_status"]["successful"], 0)
206+
self.assertEqual(response["batch_status"]["failed"], 0)
207+
self.assertEqual(response["batch_status"]["aborted"], 0)
208+
self.assertEqual(response["batch_status"]["cancelled"], 0)
209+
193210
# Send operation progress message
194211
await channel_layer.group_send(
195212
group_name,
@@ -400,12 +417,24 @@ def test_batch_upgrade_progress_publisher(self):
400417
with patch.object(
401418
publisher.channel_layer, "group_send", new_callable=AsyncMock
402419
) as mock_group_send:
403-
publisher.publish_batch_status("success", 5, 10)
420+
publisher.publish_batch_status(
421+
"success",
422+
5,
423+
10,
424+
successful=4,
425+
failed=1,
426+
aborted=0,
427+
cancelled=0,
428+
)
404429
call_args = mock_group_send.call_args[0]
405430
self.assertEqual(call_args[1]["data"]["type"], "batch_status")
406431
self.assertEqual(call_args[1]["data"]["status"], "success")
407432
self.assertEqual(call_args[1]["data"]["completed"], 5)
408433
self.assertEqual(call_args[1]["data"]["total"], 10)
434+
self.assertEqual(call_args[1]["data"]["successful"], 4)
435+
self.assertEqual(call_args[1]["data"]["failed"], 1)
436+
self.assertEqual(call_args[1]["data"]["aborted"], 0)
437+
self.assertEqual(call_args[1]["data"]["cancelled"], 0)
409438

410439
async def test_websocket_connection_errors(self):
411440
"""Test WebSocket connection error handling."""

openwisp_firmware_upgrader/websockets.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,21 @@ async def _handle_current_state_request(self, content):
248248
operations_data = await sync_to_async(
249249
lambda: UpgradeOperationSerializer(operations_list, many=True).data
250250
)()
251-
# Calculate counts
252-
total_operations = len(operations_list)
253-
completed_operations = sum(
254-
1 for op in operations_list if op.status != "in-progress"
255-
)
251+
batch_status, stats = await sync_to_async(
252+
batch_operation.calculate_and_update_status
253+
)()
256254
# Send everything in ONE message
257255
await self.send_json(
258256
{
259257
"type": "batch_state",
260258
"batch_status": {
261-
"status": batch_operation.status,
262-
"completed": completed_operations,
263-
"total": total_operations,
259+
"status": batch_status,
260+
"completed": stats["completed"],
261+
"total": stats["total_operations"],
262+
"successful": stats["successful"],
263+
"failed": stats["failed"],
264+
"aborted": stats["aborted"],
265+
"cancelled": stats["cancelled"],
264266
},
265267
"operations": operations_data,
266268
}
@@ -496,13 +498,26 @@ def publish_operation_progress(
496498
)
497499
self.publish_progress(progress_data)
498500

499-
def publish_batch_status(self, status, completed, total):
501+
def publish_batch_status(
502+
self,
503+
status,
504+
completed,
505+
total,
506+
successful=0,
507+
failed=0,
508+
aborted=0,
509+
cancelled=0,
510+
):
500511
self.publish_progress(
501512
{
502513
"type": "batch_status",
503514
"status": status,
504515
"completed": completed,
505516
"total": total,
517+
"successful": successful,
518+
"failed": failed,
519+
"aborted": aborted,
520+
"cancelled": cancelled,
506521
}
507522
)
508523

@@ -514,6 +529,10 @@ def update_batch_status(self, batch_instance):
514529
batch_status,
515530
stats["completed"],
516531
stats["total_operations"],
532+
successful=stats["successful"],
533+
failed=stats["failed"],
534+
aborted=stats["aborted"],
535+
cancelled=stats["cancelled"],
517536
)
518537

519538
@classmethod

0 commit comments

Comments
 (0)