Skip to content

Commit 7d7cfdf

Browse files
committed
✨ feat(ui): queue-aware bulk update for dashboard Updates widget
Surface Updating/Queued status in the dashboard's Recent Updates widget and Update All flow, consistent with the containers page.
1 parent 5edc55a commit 7d7cfdf

File tree

5 files changed

+320
-39
lines changed

5 files changed

+320
-39
lines changed

ui/src/views/DashboardView.vue

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import DashboardSecurityOverviewWidget from './dashboard/components/DashboardSec
1818
import DashboardUpdateBreakdownWidget from './dashboard/components/DashboardUpdateBreakdownWidget.vue';
1919
import {
2020
DASHBOARD_WIDGET_META,
21+
type DashboardUpdateSequenceEntry,
2122
type DashboardWidgetId,
2223
type RecentUpdateRow,
2324
} from './dashboard/dashboardTypes';
@@ -48,6 +49,7 @@ const dashboardUpdateError = ref<string | null>(null);
4849
const dashboardPendingUpdateRows = ref<Map<string, { row: RecentUpdateRow; startedAt: number }>>(
4950
new Map(),
5051
);
52+
const dashboardUpdateSequence = ref<Map<string, DashboardUpdateSequenceEntry>>(new Map());
5153
const dashboardPendingUpdatePollTimer = ref<ReturnType<typeof setTimeout> | null>(null);
5254
const dashboardPendingUpdatePollInFlight = ref(false);
5355
const dashboardPendingUpdatePollDelayMs = ref(2_000);
@@ -193,7 +195,10 @@ const {
193195
});
194196
195197
const pendingUpdates = computed(() =>
196-
recentUpdates.value.filter((r) => r.status === 'pending' && !r.blocked),
198+
recentUpdates.value.filter(
199+
(row) =>
200+
row.status === 'pending' && !row.blocked && !dashboardUpdateSequence.value.has(row.name),
201+
),
197202
);
198203
199204
const displayRecentUpdates = computed<RecentUpdateRow[]>(() => {
@@ -214,8 +219,59 @@ function stopDashboardPendingUpdatePolling() {
214219
dashboardPendingUpdatePollDelayMs.value = DASHBOARD_PENDING_UPDATE_POLL_INTERVAL_MS;
215220
}
216221
222+
function hasDashboardTrackedUpdates() {
223+
return dashboardPendingUpdateRows.value.size > 0 || dashboardUpdateSequence.value.size > 0;
224+
}
225+
226+
function getVisibleDashboardTrackedUpdateNames() {
227+
const names = new Set(recentUpdates.value.map((row) => row.name));
228+
for (const name of dashboardPendingUpdateRows.value.keys()) {
229+
names.add(name);
230+
}
231+
return names;
232+
}
233+
234+
function startDashboardPendingUpdateTracking() {
235+
if (!hasDashboardTrackedUpdates()) {
236+
stopDashboardPendingUpdatePolling();
237+
return;
238+
}
239+
stopDashboardPendingUpdatePolling();
240+
dashboardPendingUpdatePollDelayMs.value = DASHBOARD_PENDING_UPDATE_POLL_INTERVAL_MS;
241+
startDashboardPendingUpdatePolling();
242+
}
243+
244+
function syncDashboardUpdateSequenceValue(rowNames: string[], acceptedRowNames: string[]) {
245+
const next = new Map(dashboardUpdateSequence.value);
246+
for (const name of rowNames) {
247+
next.delete(name);
248+
}
249+
for (const [index, name] of acceptedRowNames.entries()) {
250+
next.set(name, {
251+
position: index + 1,
252+
total: acceptedRowNames.length,
253+
});
254+
}
255+
dashboardUpdateSequence.value = next;
256+
}
257+
258+
function pruneDashboardUpdateSequence() {
259+
const visibleNames = getVisibleDashboardTrackedUpdateNames();
260+
if (dashboardUpdateAllInProgress.value && visibleNames.size === 0) {
261+
return;
262+
}
263+
const next = new Map(dashboardUpdateSequence.value);
264+
for (const name of dashboardUpdateSequence.value.keys()) {
265+
if (!visibleNames.has(name)) {
266+
next.delete(name);
267+
}
268+
}
269+
dashboardUpdateSequence.value = next;
270+
}
271+
217272
function clearDashboardPendingUpdateRow(name: string) {
218273
dashboardPendingUpdateRows.value.delete(name);
274+
pruneDashboardUpdateSequence();
219275
}
220276
221277
function pruneDashboardPendingUpdateRows(now: number = Date.now()) {
@@ -228,7 +284,8 @@ function pruneDashboardPendingUpdateRows(now: number = Date.now()) {
228284
clearDashboardPendingUpdateRow(name);
229285
}
230286
}
231-
if (dashboardPendingUpdateRows.value.size === 0) {
287+
pruneDashboardUpdateSequence();
288+
if (!hasDashboardTrackedUpdates()) {
232289
stopDashboardPendingUpdatePolling();
233290
}
234291
}
@@ -243,9 +300,9 @@ async function pollDashboardPendingUpdateRows() {
243300
} finally {
244301
pruneDashboardPendingUpdateRows();
245302
dashboardPendingUpdatePollInFlight.value = false;
246-
if (dashboardPendingUpdateRows.value.size > 0) {
303+
if (hasDashboardTrackedUpdates()) {
247304
dashboardPendingUpdatePollDelayMs.value =
248-
pendingUpdates.value.length > 0
305+
dashboardUpdateSequence.value.size > 0 || pendingUpdates.value.length > 0
249306
? DASHBOARD_PENDING_UPDATE_POLL_INTERVAL_MS
250307
: Math.min(
251308
dashboardPendingUpdatePollDelayMs.value * 2,
@@ -281,12 +338,8 @@ function capturePendingDashboardRows(rows: RecentUpdateRow[]) {
281338
startedAt: existing?.startedAt ?? Date.now(),
282339
});
283340
}
284-
if (dashboardPendingUpdateRows.value.size > 0) {
285-
stopDashboardPendingUpdatePolling();
286-
dashboardPendingUpdatePollDelayMs.value = DASHBOARD_PENDING_UPDATE_POLL_INTERVAL_MS;
287-
startDashboardPendingUpdatePolling();
288-
}
289341
pruneDashboardPendingUpdateRows();
342+
startDashboardPendingUpdateTracking();
290343
}
291344
292345
// Stat card data lookup by widget id
@@ -298,6 +351,7 @@ const statById = computed(() => {
298351
299352
watch(containers, () => {
300353
pruneDashboardPendingUpdateRows();
354+
pruneDashboardUpdateSequence();
301355
});
302356
303357
onUnmounted(() => {
@@ -378,17 +432,31 @@ function confirmDashboardUpdateAll() {
378432
const pendingRowsSnapshot = pendingUpdates.value.filter((row) => !row.blocked);
379433
dashboardUpdateAllInProgress.value = true;
380434
dashboardUpdateError.value = null;
435+
const snapshotRowNames = pendingRowsSnapshot.map((row) => row.name);
436+
let acceptedRowNames = [...snapshotRowNames];
437+
syncDashboardUpdateSequenceValue(snapshotRowNames, acceptedRowNames);
438+
startDashboardPendingUpdateTracking();
381439
try {
382-
const updateResults = await Promise.allSettled(
383-
pendingRowsSnapshot.map((row) => updateContainer(row.id)),
384-
);
385-
const successfulRows = pendingRowsSnapshot.filter(
386-
(_, index) => updateResults[index]?.status === 'fulfilled',
387-
);
388-
const staleRows = pendingRowsSnapshot.filter((_, index) => {
389-
const result = updateResults[index];
390-
return result?.status === 'rejected' && isStaleDashboardUpdateError(result.reason);
391-
});
440+
const successfulRows: RecentUpdateRow[] = [];
441+
const staleRows: RecentUpdateRow[] = [];
442+
let firstRejectedUpdate: unknown;
443+
444+
for (const row of pendingRowsSnapshot) {
445+
try {
446+
await updateContainer(row.id);
447+
successfulRows.push(row);
448+
} catch (e: unknown) {
449+
acceptedRowNames = acceptedRowNames.filter((name) => name !== row.name);
450+
syncDashboardUpdateSequenceValue(snapshotRowNames, acceptedRowNames);
451+
452+
if (isStaleDashboardUpdateError(e)) {
453+
staleRows.push(row);
454+
} else if (!firstRejectedUpdate) {
455+
firstRejectedUpdate = e;
456+
}
457+
}
458+
}
459+
392460
await fetchDashboardData();
393461
capturePendingDashboardRows(successfulRows);
394462
if (successfulRows.length > 0) {
@@ -401,16 +469,17 @@ function confirmDashboardUpdateAll() {
401469
: `${formatContainerCount(staleRows.length)} already up to date`,
402470
);
403471
}
404-
const firstRejectedUpdate = updateResults.find(
405-
(result) => result.status === 'rejected' && !isStaleDashboardUpdateError(result.reason),
406-
);
407-
if (firstRejectedUpdate?.status === 'rejected') {
472+
if (firstRejectedUpdate) {
408473
dashboardUpdateError.value = errorMessage(
409-
firstRejectedUpdate.reason,
474+
firstRejectedUpdate,
410475
'Failed to update all containers',
411476
);
412477
}
413478
} finally {
479+
if (acceptedRowNames.length === 0) {
480+
syncDashboardUpdateSequenceValue(snapshotRowNames, []);
481+
pruneDashboardUpdateSequence();
482+
}
414483
dashboardUpdateAllInProgress.value = false;
415484
}
416485
},
@@ -543,6 +612,7 @@ function confirmDashboardUpdateAll() {
543612
:dashboard-update-error="dashboardUpdateError"
544613
:dashboard-update-in-progress="dashboardUpdateInProgress"
545614
:dashboard-update-all-in-progress="dashboardUpdateAllInProgress"
615+
:dashboard-update-sequence="dashboardUpdateSequence"
546616
:get-update-kind-color="getUpdateKindColor"
547617
:get-update-kind-icon="getUpdateKindIcon"
548618
:get-update-kind-muted-color="getUpdateKindMutedColor"

ui/src/views/dashboard/components/DashboardRecentUpdatesWidget.vue

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
33
import AppBadge from '@/components/AppBadge.vue';
44
import AppIconButton from '@/components/AppIconButton.vue';
55
import { useBreakpoints } from '@/composables/useBreakpoints';
6-
import type { RecentUpdateRow, UpdateKind } from '../dashboardTypes';
6+
import type { DashboardUpdateSequenceEntry, RecentUpdateRow, UpdateKind } from '../dashboardTypes';
77
88
const { isMobile } = useBreakpoints();
99
@@ -28,6 +28,7 @@ interface Props {
2828
dashboardUpdateAllInProgress: boolean;
2929
dashboardUpdateError: string | null;
3030
dashboardUpdateInProgress: string | null;
31+
dashboardUpdateSequence: Map<string, DashboardUpdateSequenceEntry>;
3132
editMode: boolean;
3233
getUpdateKindColor: (kind: UpdateKind | null) => string;
3334
getUpdateKindIcon: (kind: UpdateKind | null) => string;
@@ -38,18 +39,68 @@ interface Props {
3839
3940
const props = defineProps<Props>();
4041
41-
function isRowUpdating(row: Record<string, unknown>): boolean {
42-
const id = row.id as string;
42+
const dashboardUpdateSequenceHeadPosition = computed<number | null>(() => {
43+
let headPosition: number | null = null;
44+
for (const sequence of props.dashboardUpdateSequence.values()) {
45+
if (headPosition === null || sequence.position < headPosition) {
46+
headPosition = sequence.position;
47+
}
48+
}
49+
return headPosition;
50+
});
51+
52+
const isDashboardBulkUpdateLocked = computed(
53+
() => props.dashboardUpdateAllInProgress || props.dashboardUpdateSequence.size > 0,
54+
);
55+
56+
function getRowSequence(row: Record<string, unknown>) {
57+
const name = row.name as string | undefined;
58+
return name ? props.dashboardUpdateSequence.get(name) : undefined;
59+
}
60+
61+
function getRowUpdateState(row: Record<string, unknown>): 'queued' | 'updating' | null {
4362
const status = row.status as string | undefined;
44-
return (
45-
status === 'updating' ||
46-
props.dashboardUpdateInProgress === id ||
47-
props.dashboardUpdateAllInProgress
48-
);
63+
const sequence = getRowSequence(row);
64+
if (sequence) {
65+
return dashboardUpdateSequenceHeadPosition.value === sequence.position ? 'updating' : 'queued';
66+
}
67+
68+
if (status === 'queued') {
69+
return 'queued';
70+
}
71+
72+
const id = row.id as string;
73+
if (status === 'updating' || props.dashboardUpdateInProgress === id) {
74+
return 'updating';
75+
}
76+
77+
return null;
78+
}
79+
80+
function isRowUpdating(row: Record<string, unknown>): boolean {
81+
return getRowUpdateState(row) === 'updating';
82+
}
83+
84+
function isRowQueued(row: Record<string, unknown>): boolean {
85+
return getRowUpdateState(row) === 'queued';
86+
}
87+
88+
function getRowUpdateLabel(row: Record<string, unknown>): string {
89+
const updateState = getRowUpdateState(row);
90+
if (!updateState) {
91+
return '';
92+
}
93+
94+
const sequence = getRowSequence(row);
95+
const baseLabel = updateState === 'queued' ? 'Queued' : 'Updating';
96+
if (!sequence || sequence.total <= 1) {
97+
return baseLabel;
98+
}
99+
return `${baseLabel} ${sequence.position} of ${sequence.total}`;
49100
}
50101
51102
function getRowClass(row: Record<string, unknown>): string {
52-
if (isRowUpdating(row)) {
103+
if (isRowUpdating(row) || isRowQueued(row)) {
53104
return 'opacity-50 pointer-events-none transition-opacity duration-300';
54105
}
55106
return '';
@@ -126,10 +177,10 @@ watchEffect(() => {
126177
weight="none"
127178
type="button"
128179
class="inline-flex items-center justify-center px-2 py-1 dd-rounded border text-2xs font-semibold transition-colors"
129-
:class="dashboardUpdateAllInProgress
180+
:class="isDashboardBulkUpdateLocked
130181
? 'dd-text-muted cursor-not-allowed opacity-60'
131182
: 'dd-text hover:dd-bg-elevated'"
132-
:disabled="dashboardUpdateAllInProgress"
183+
:disabled="isDashboardBulkUpdateLocked"
133184
@click="handleConfirmUpdateAll">
134185
<AppIcon
135186
:name="dashboardUpdateAllInProgress ? 'spinner' : 'cloud-download'"
@@ -181,6 +232,7 @@ watchEffect(() => {
181232
<div class="flex items-start gap-2">
182233
<div v-if="isMobile" class="shrink-0 mt-0.5">
183234
<AppIcon v-if="isRowUpdating(row)" name="spinner" :size="16" class="dd-spin dd-text-muted" />
235+
<AppIcon v-else-if="isRowQueued(row)" name="clock" :size="16" class="dd-text-muted" />
184236
<ContainerIcon v-else :icon="row.icon" :size="20" />
185237
</div>
186238
<div class="min-w-0">
@@ -191,7 +243,14 @@ watchEffect(() => {
191243
size="xs"
192244
:custom="{ bg: 'var(--dd-warning-muted)', text: 'var(--dd-warning)' }">
193245
<AppIcon name="spinner" :size="12" class="mr-1 dd-spin" />
194-
Updating
246+
{{ getRowUpdateLabel(row) }}
247+
</AppBadge>
248+
<AppBadge
249+
v-else-if="isRowQueued(row)"
250+
size="xs"
251+
:custom="{ bg: 'var(--dd-warning-muted)', text: 'var(--dd-warning)' }">
252+
<AppIcon name="clock" :size="12" class="mr-1" />
253+
{{ getRowUpdateLabel(row) }}
195254
</AppBadge>
196255
</div>
197256
<div class="text-2xs dd-text-muted mt-0.5 truncate">{{ row.image }}</div>
@@ -267,9 +326,15 @@ watchEffect(() => {
267326
<span
268327
v-if="isRowUpdating(row)"
269328
class="w-7 h-7 dd-rounded-sm flex items-center justify-center dd-text-muted"
270-
v-tooltip.top="'Updating'">
329+
v-tooltip.top="getRowUpdateLabel(row)">
271330
<AppIcon name="spinner" :size="14" class="dd-spin" />
272331
</span>
332+
<span
333+
v-else-if="isRowQueued(row)"
334+
class="w-7 h-7 dd-rounded-sm flex items-center justify-center dd-text-muted"
335+
v-tooltip.top="getRowUpdateLabel(row)">
336+
<AppIcon name="clock" :size="14" />
337+
</span>
273338
<span
274339
v-else-if="row.blocked"
275340
class="w-7 h-7 dd-rounded-sm flex items-center justify-center dd-text-muted opacity-60 cursor-not-allowed"
@@ -283,10 +348,10 @@ watchEffect(() => {
283348
variant="plain"
284349
data-test="dashboard-update-btn"
285350
class="dd-rounded-sm transition-colors"
286-
:class="dashboardUpdateInProgress === row.id || dashboardUpdateAllInProgress
351+
:class="dashboardUpdateInProgress === row.id || isDashboardBulkUpdateLocked
287352
? 'dd-text-muted opacity-50 cursor-not-allowed'
288353
: 'dd-text-muted hover:dd-text-success hover:dd-bg-elevated'"
289-
:disabled="dashboardUpdateInProgress === row.id || dashboardUpdateAllInProgress"
354+
:disabled="dashboardUpdateInProgress === row.id || isDashboardBulkUpdateLocked"
290355
:loading="dashboardUpdateInProgress === row.id"
291356
tooltip="Update container"
292357
aria-label="Update container"

ui/src/views/dashboard/dashboardTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,19 @@ export interface RecentUpdateRow {
148148
| 'snoozed'
149149
| 'skipped'
150150
| 'maturity-blocked'
151+
| 'queued'
151152
| 'updating';
152153
updateKind: UpdateKind | null;
153154
running: boolean;
154155
registryError?: string;
155156
blocked: boolean;
156157
}
157158

159+
export interface DashboardUpdateSequenceEntry {
160+
position: number;
161+
total: number;
162+
}
163+
158164
export interface DashboardServerRow {
159165
name: string;
160166
host?: string;

0 commit comments

Comments
 (0)