Skip to content

Commit b6a8cde

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

4 files changed

Lines changed: 208 additions & 47 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: 106 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -140,61 +140,125 @@ 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 segmentsHtml = segments
192+
.map(function (seg, index) {
193+
let segmentWidth =
194+
index === segments.length - 1
195+
? progressPercentage - usedWidth
196+
: Math.round((seg.count / total) * 100);
197+
usedWidth += segmentWidth;
198+
return (
199+
'<div class="upgrade-progress-fill ' +
200+
escapeHtml(seg.cssClass) +
201+
'" style="width: ' +
202+
escapeHtml(String(segmentWidth)) +
203+
'%"></div>'
204+
);
205+
})
206+
.join("");
207+
progressHtml = '<div class="upgrade-progress-bar">' + segmentsHtml + "</div>";
208+
// legend builing function
209+
if (segments.length > 0) {
210+
let legendItems = segments
211+
.map(function (seg) {
212+
return (
213+
'<span class="legend-item">' +
214+
'<span class="legend-dot ' +
215+
escapeHtml(seg.cssClass) +
216+
'"></span>' +
217+
escapeHtml(String(seg.count)) +
218+
" " +
219+
escapeHtml(seg.label) +
220+
"</span>"
221+
);
222+
})
223+
.join("");
224+
legendHtml = '<div class="batch-progress-legend">' + legendItems + "</div>";
225+
}
226+
if (
227+
data.status === FW_UPGRADE_STATUS.FAILED ||
228+
data.status === FW_UPGRADE_STATUS.CANCELLED
229+
) {
230+
showPercentageText = false;
231+
}
232+
} else {
233+
// Fallback: single-color will be rendered when per-status counts are not available
234+
let statusClass = FW_UPGRADE_CSS_CLASSES.IN_PROGRESS;
235+
if (data.status === FW_UPGRADE_STATUS.SUCCESS) {
175236
progressPercentage = 100;
176-
statusClass = FW_UPGRADE_CSS_CLASSES.PARTIAL_SUCCESS;
237+
statusClass = FW_UPGRADE_CSS_CLASSES.COMPLETED_SUCCESSFULLY;
238+
} else if (data.status === FW_UPGRADE_STATUS.CANCELLED) {
239+
progressPercentage = 100;
240+
statusClass = FW_UPGRADE_CSS_CLASSES.CANCELLED;
177241
showPercentageText = false;
178-
} else {
179-
// All operations failed - total failure (red)
242+
} else if (data.status === FW_UPGRADE_STATUS.FAILED) {
180243
progressPercentage = 100;
181244
statusClass = FW_UPGRADE_CSS_CLASSES.FAILED;
182245
showPercentageText = false;
183246
}
247+
progressHtml =
248+
'<div class="upgrade-progress-bar">' +
249+
'<div class="upgrade-progress-fill ' +
250+
escapeHtml(statusClass) +
251+
'" style="width: ' +
252+
escapeHtml(String(progressPercentage)) +
253+
'%"></div></div>';
184254
}
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-
`;
192255
if (showPercentageText) {
193-
progressHtml += `<span class="upgrade-progress-text">
194-
${escapeHtml(String(progressPercentage))}%
195-
</span>`;
256+
progressHtml +=
257+
'<span class="upgrade-progress-text">' +
258+
escapeHtml(String(progressPercentage)) +
259+
"%</span>";
196260
}
197-
mainProgressElement.html(progressHtml);
261+
mainProgressElement.html(progressHtml + legendHtml);
198262
}
199263
// Update completion information in the admin form if available
200264
if (data.total !== undefined && data.completed !== undefined) {

openwisp_firmware_upgrader/tests/test_websockets.py

Lines changed: 21 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,10 @@ 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)
193201
# Send operation progress message
194202
await channel_layer.group_send(
195203
group_name,
@@ -400,12 +408,24 @@ def test_batch_upgrade_progress_publisher(self):
400408
with patch.object(
401409
publisher.channel_layer, "group_send", new_callable=AsyncMock
402410
) as mock_group_send:
403-
publisher.publish_batch_status("success", 5, 10)
411+
publisher.publish_batch_status(
412+
"success",
413+
5,
414+
10,
415+
successful=4,
416+
failed=1,
417+
aborted=0,
418+
cancelled=0,
419+
)
404420
call_args = mock_group_send.call_args[0]
405421
self.assertEqual(call_args[1]["data"]["type"], "batch_status")
406422
self.assertEqual(call_args[1]["data"]["status"], "success")
407423
self.assertEqual(call_args[1]["data"]["completed"], 5)
408424
self.assertEqual(call_args[1]["data"]["total"], 10)
425+
self.assertEqual(call_args[1]["data"]["successful"], 4)
426+
self.assertEqual(call_args[1]["data"]["failed"], 1)
427+
self.assertEqual(call_args[1]["data"]["aborted"], 0)
428+
self.assertEqual(call_args[1]["data"]["cancelled"], 0)
409429

410430
async def test_websocket_connection_errors(self):
411431
"""Test WebSocket connection error handling."""

openwisp_firmware_upgrader/websockets.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ async def _handle_current_state_request(self, content):
253253
completed_operations = sum(
254254
1 for op in operations_list if op.status != "in-progress"
255255
)
256+
successful = sum(1 for op in operations_list if op.status == "success")
257+
failed = sum(1 for op in operations_list if op.status == "failed")
258+
aborted = sum(1 for op in operations_list if op.status == "aborted")
259+
cancelled = sum(1 for op in operations_list if op.status == "cancelled")
256260
# Send everything in ONE message
257261
await self.send_json(
258262
{
@@ -261,6 +265,10 @@ async def _handle_current_state_request(self, content):
261265
"status": batch_operation.status,
262266
"completed": completed_operations,
263267
"total": total_operations,
268+
"successful": successful,
269+
"failed": failed,
270+
"aborted": aborted,
271+
"cancelled": cancelled,
264272
},
265273
"operations": operations_data,
266274
}
@@ -496,13 +504,26 @@ def publish_operation_progress(
496504
)
497505
self.publish_progress(progress_data)
498506

499-
def publish_batch_status(self, status, completed, total):
507+
def publish_batch_status(
508+
self,
509+
status,
510+
completed,
511+
total,
512+
successful=0,
513+
failed=0,
514+
aborted=0,
515+
cancelled=0,
516+
):
500517
self.publish_progress(
501518
{
502519
"type": "batch_status",
503520
"status": status,
504521
"completed": completed,
505522
"total": total,
523+
"successful": successful,
524+
"failed": failed,
525+
"aborted": aborted,
526+
"cancelled": cancelled,
506527
}
507528
)
508529

@@ -514,6 +535,10 @@ def update_batch_status(self, batch_instance):
514535
batch_status,
515536
stats["completed"],
516537
stats["total_operations"],
538+
successful=stats["successful"],
539+
failed=stats["failed"],
540+
aborted=stats["aborted"],
541+
cancelled=stats["cancelled"],
517542
)
518543

519544
@classmethod

0 commit comments

Comments
 (0)