fix(admin): make bulk delete resilient + persistent error toasts

This commit is contained in:
Young Lee
2026-02-06 15:10:55 -08:00
parent 1c1de9699e
commit de7978f7bc
3 changed files with 207 additions and 108 deletions
+142 -55
View File
@@ -1168,9 +1168,9 @@ app.get("/", async (c) => {
}); });
} catch (error) { } catch (error) {
if (toast && toast.update) { if (toast && toast.update) {
toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false, duration: 6500 }); toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false });
} else if (window.showToast) { } else if (window.showToast) {
window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 6500 }); window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error' });
} }
setButtonLoading(button, false); setButtonLoading(button, false);
confirming = false; confirming = false;
@@ -1362,10 +1362,10 @@ app.get("/", async (c) => {
const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch; const deletedIds = Array.isArray(data.deletedFeedIds) ? data.deletedFeedIds : batch;
const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : []; const failedIds = Array.isArray(data.failedFeedIds) ? data.failedFeedIds : [];
const failureDetails = Array.isArray(data.failures) ? data.failures : [];
removeFeedRowsById(deletedIds); removeFeedRowsById(deletedIds);
deletedTotal += deletedIds.length; deletedTotal += deletedIds.length;
failed.push(...failedIds);
if (toast && toast.update) { if (toast && toast.update) {
const done = Math.min(i + batch.length, selectedIds.length); const done = Math.min(i + batch.length, selectedIds.length);
@@ -1375,14 +1375,62 @@ app.get("/", async (c) => {
// Keep selection state consistent as rows disappear. // Keep selection state consistent as rows disappear.
updateFeedMatchCount(); updateFeedMatchCount();
updateFeedSelectionState(); updateFeedSelectionState();
// If a batch fails for some feeds, retry those one-by-one using the single delete endpoint.
if (failedIds.length > 0) {
if (toast && toast.update) {
toast.update('Retrying ' + failedIds.length + ' failed feed(s) one-by-one...', { type: 'info' });
}
const stillFailed = [];
for (let j = 0; j < failedIds.length; j++) {
const feedId = String(failedIds[j] || '');
if (!feedId) continue;
try {
const retryRes = await fetch('/admin/feeds/' + encodeURIComponent(feedId) + '/delete?view=table', {
method: 'POST',
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (window.parseJsonResponseOrThrow) {
await window.parseJsonResponseOrThrow(retryRes, { prefix: 'Retry delete failed' });
} else if (!retryRes.ok) {
throw new Error('Retry delete failed (HTTP ' + retryRes.status + ')');
}
removeFeedRowsById([feedId]);
deletedTotal += 1;
} catch (e) {
stillFailed.push(feedId);
}
if (toast && toast.update) {
toast.update('Retrying... (' + (j + 1) + ' of ' + failedIds.length + ')', { type: 'info' });
}
}
// Replace failed ids from this batch with only the ones that still failed after retry.
if (stillFailed.length > 0) {
failed.push(...stillFailed);
if (window.showToast && failureDetails.length > 0) {
const first = failureDetails[0] && failureDetails[0].error ? String(failureDetails[0].error) : '';
if (first) {
window.showToast('Some feeds failed to delete: ' + first, { type: 'error' });
}
}
}
updateFeedMatchCount();
updateFeedSelectionState();
}
} }
if (toast && toast.dismiss) toast.dismiss(); if (toast && toast.dismiss) toast.dismiss();
if (failed.length > 0) { const uniqueFailed = Array.from(new Set(failed.map((v) => String(v)).filter(Boolean)));
if (uniqueFailed.length > 0) {
if (window.showToast) { if (window.showToast) {
window.showToast( window.showToast(
'Deleted ' + deletedTotal + ' feed(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.', 'Deleted ' + deletedTotal + ' feed(s). ' + uniqueFailed.length + ' failed (still visible).',
{ type: 'error', duration: 6500 }, { type: 'error' },
); );
} }
} else { } else {
@@ -1391,7 +1439,7 @@ app.get("/", async (c) => {
} catch (error) { } catch (error) {
if (toast && toast.dismiss) toast.dismiss(); if (toast && toast.dismiss) toast.dismiss();
if (window.showToast) { if (window.showToast) {
window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error', duration: 7500 }); window.showToast((error && error.message) ? error.message : 'Bulk feed delete failed.', { type: 'error' });
} }
} finally { } finally {
FEED_BULK_DELETE_IN_PROGRESS = false; FEED_BULK_DELETE_IN_PROGRESS = false;
@@ -1655,15 +1703,45 @@ async function deleteFeedFast(
emailStorage: KVNamespace, emailStorage: KVNamespace,
feedId: string, feedId: string,
): Promise<boolean> { ): Promise<boolean> {
const result = await deleteFeedFastDetailed(emailStorage, feedId);
return result.ok;
}
type DeleteFeedFastResult = {
// "ok" means the feed is deactivated (config deleted). Metadata is best-effort.
ok: boolean;
configDeleted: boolean;
metadataDeleted: boolean;
errors: string[];
};
async function deleteFeedFastDetailed(
emailStorage: KVNamespace,
feedId: string,
): Promise<DeleteFeedFastResult> {
const feedConfigKey = `feed:${feedId}:config`; const feedConfigKey = `feed:${feedId}:config`;
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
const results = await Promise.allSettled([ const errors: string[] = [];
emailStorage.delete(feedConfigKey), let configDeleted = false;
emailStorage.delete(feedMetadataKey), let metadataDeleted = false;
]);
return results.every((r) => r.status === "fulfilled"); try {
await emailStorage.delete(feedConfigKey);
configDeleted = true;
} catch (error) {
errors.push(`config delete failed: ${String(error)}`);
}
// Best-effort: if config is gone the feed is effectively disabled.
try {
await emailStorage.delete(feedMetadataKey);
metadataDeleted = true;
} catch (error) {
errors.push(`metadata delete failed: ${String(error)}`);
}
return { ok: configDeleted, configDeleted, metadataDeleted, errors };
} }
async function purgeFeedKeysStep( async function purgeFeedKeysStep(
@@ -1679,7 +1757,7 @@ async function purgeFeedKeysStep(
const prefix = `feed:${feedId}:`; const prefix = `feed:${feedId}:`;
const limit = Math.min( const limit = Math.min(
1000, 1000,
Math.max(1, Math.floor(options.limit || 250)), Math.max(1, Math.floor(options.limit || 100)),
); );
const cursor = options.cursor || undefined; const cursor = options.cursor || undefined;
@@ -1736,9 +1814,10 @@ app.post("/feeds/:feedId/purge", async (c) => {
} | null; } | null;
const cursor = body?.cursor ? String(body.cursor) : undefined; const cursor = body?.cursor ? String(body.cursor) : undefined;
// Keep purge requests small to avoid Cloudflare per-request limits.
const limit = Number.isFinite(Number(body?.limit)) const limit = Number.isFinite(Number(body?.limit))
? Number(body?.limit) ? Number(body?.limit)
: 250; : 100;
const step = await purgeFeedKeysStep(emailStorage, feedId, { const step = await purgeFeedKeysStep(emailStorage, feedId, {
cursor, cursor,
@@ -1794,40 +1873,61 @@ app.post("/feeds/bulk-delete", async (c) => {
); );
} }
const results: Array<{ feedId: string; ok: boolean }> = []; const okIds: string[] = [];
const concurrency = 10; const failures: Array<{ feedId: string; error: string }> = [];
const warnings: Array<{ feedId: string; warning: string }> = [];
for (let i = 0; i < parsedFeedIds.length; i += concurrency) { // Keep this request intentionally small/cheap (the UI already batches calls).
const batch = parsedFeedIds.slice(i, i + concurrency); for (const feedId of parsedFeedIds) {
const batchResults = await Promise.all(
batch.map(async (feedId) => {
try { try {
const ok = await deleteFeedFast(emailStorage, feedId); const result = await deleteFeedFastDetailed(emailStorage, feedId);
return { feedId, ok }; if (!result.ok) {
failures.push({
feedId,
error:
result.errors.join("; ") ||
"Failed to delete feed config (feed may still be active).",
});
continue;
}
if (!result.metadataDeleted) {
warnings.push({
feedId,
warning:
"Feed config deleted, but metadata cleanup failed. This is usually safe, but storage cleanup may be incomplete.",
});
}
okIds.push(feedId);
} catch (error) { } catch (error) {
console.error("Error bulk deleting feed:", feedId, error); console.error("Error bulk deleting feed:", feedId, error);
return { feedId, ok: false }; failures.push({ feedId, error: String(error) });
} }
}),
);
results.push(...batchResults);
} }
const okIds = results.filter((r) => r.ok).map((r) => r.feedId);
const failedFeedIds = results.filter((r) => !r.ok).map((r) => r.feedId);
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
// Best-effort: kick off small purge steps in the background so storage starts clearing. // If config deletion succeeded but list removal didn't, surface it explicitly.
// The UI also runs purge steps for full cleanup + progress. const removed = new Set(deletedFeedIds);
deletedFeedIds.forEach((feedId) => { okIds.forEach((feedId) => {
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId)); if (!removed.has(feedId)) {
failures.push({
feedId,
error:
"Feed config deleted, but failed to remove it from feeds:list. Refresh and try again.",
}); });
}
});
const failedFeedIds = Array.from(new Set(failures.map((f) => f.feedId)));
return c.json({ return c.json({
ok: failedFeedIds.length === 0, ok: failedFeedIds.length === 0,
deletedFeedIds, deletedFeedIds,
failedFeedIds, failedFeedIds,
failures,
warnings,
}); });
} }
@@ -1841,32 +1941,19 @@ app.post("/feeds/bulk-delete", async (c) => {
return c.redirect(`${redirectBase}&message=bulkDeleteNoop`); return c.redirect(`${redirectBase}&message=bulkDeleteNoop`);
} }
const results: Array<{ feedId: string; ok: boolean }> = []; const okIds: string[] = [];
const concurrency = 10;
for (let i = 0; i < parsedFeedIds.length; i += concurrency) { for (const feedId of parsedFeedIds) {
const batch = parsedFeedIds.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(async (feedId) => {
try { try {
const ok = await deleteFeedFast(emailStorage, feedId); const result = await deleteFeedFastDetailed(emailStorage, feedId);
return { feedId, ok }; if (result.ok) okIds.push(feedId);
} catch (error) { } catch (error) {
console.error("Error bulk deleting feed:", feedId, error); console.error("Error bulk deleting feed:", feedId, error);
return { feedId, ok: false };
} }
}),
);
results.push(...batchResults);
} }
const okIds = results.filter((r) => r.ok).map((r) => r.feedId);
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
deletedFeedIds.forEach((feedId) => {
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId));
});
return c.redirect( return c.redirect(
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
); );
@@ -2511,9 +2598,9 @@ app.get("/feeds/:feedId/emails", async (c) => {
}); });
} catch (error) { } catch (error) {
if (toast && toast.update) { if (toast && toast.update) {
toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false, duration: 6500 }); toast.update('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', loading: false });
} else if (window.showToast) { } else if (window.showToast) {
window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error', duration: 6500 }); window.showToast('Delete failed: ' + (error && error.message ? error.message : 'Unknown error'), { type: 'error' });
} }
setButtonLoading(button, false); setButtonLoading(button, false);
confirming = false; confirming = false;
@@ -2718,8 +2805,8 @@ app.get("/feeds/:feedId/emails", async (c) => {
if (failed.length > 0) { if (failed.length > 0) {
if (window.showToast) { if (window.showToast) {
window.showToast( window.showToast(
'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible). Try again; Cloudflare limits can cause temporary failures.', 'Deleted ' + deletedTotal + ' email(s). ' + failed.length + ' failed (still visible).',
{ type: 'error', duration: 6500 }, { type: 'error' },
); );
} }
} else { } else {
@@ -2728,7 +2815,7 @@ app.get("/feeds/:feedId/emails", async (c) => {
} catch (error) { } catch (error) {
if (toast && toast.dismiss) toast.dismiss(); if (toast && toast.dismiss) toast.dismiss();
if (window.showToast) { if (window.showToast) {
window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error', duration: 7500 }); window.showToast((error && error.message) ? error.message : 'Bulk email delete failed.', { type: 'error' });
} }
} finally { } finally {
EMAIL_BULK_DELETE_IN_PROGRESS = false; EMAIL_BULK_DELETE_IN_PROGRESS = false;
+17 -6
View File
@@ -52,7 +52,22 @@ export const toastScripts = `
function showToast(message, opts) { function showToast(message, opts) {
const options = opts || {}; const options = opts || {};
const duration = Number.isFinite(options.duration) ? Number(options.duration) : 4500; const type = (options && typeof options.type === 'string') ? String(options.type) : 'info';
const loading = !!(options && options.loading);
// Default durations:
// - error: persistent (user must dismiss) so messages can be copied and acted on
// - info/success/warning: auto-dismiss (notifications)
const defaultDurationByType = {
info: 4500,
success: 3500,
warning: 6500,
error: 0,
};
const duration = Number.isFinite(options.duration)
? Number(options.duration)
: (loading ? 0 : (defaultDurationByType[type] ?? 4500));
const stack = ensureToastStack(); const stack = ensureToastStack();
const { toast, text, close, body } = createToastEl(message, options); const { toast, text, close, body } = createToastEl(message, options);
@@ -103,11 +118,7 @@ export const toastScripts = `
} }
close.addEventListener('click', dismiss); close.addEventListener('click', dismiss);
toast.addEventListener('click', (e) => { // Don't dismiss on toast-body clicks: people should be able to select/copy text.
// Clicking the toast itself dismisses, but keep buttons functional
if (e.target === close) return;
dismiss();
});
stack.appendChild(toast); stack.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('visible')); requestAnimationFrame(() => toast.classList.add('visible'));
+1
View File
@@ -968,6 +968,7 @@ export const componentStyles = `
word-break: break-word; word-break: break-word;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
user-select: text;
} }
.toast-close { .toast-close {