From fce6dd113854ff73377d7bb931d397996d3ff95e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 20:08:29 +0200 Subject: [PATCH] feat(campaign-ui): add explicit job retry controls --- webui/src/api/campaigns.ts | 63 +++++++++++++- .../features/campaigns/CampaignReportPage.tsx | 84 ++++++++++++++++++- 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index f29adf6..e7783cb 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -313,6 +313,8 @@ export type CampaignAttachmentPreviewFile = { checksum_sha256?: string; size_bytes?: number; content_type?: string | null; + linked_to_campaign?: boolean; + share?: Record | null; }; export type CampaignAttachmentPreviewRule = { @@ -334,6 +336,8 @@ export type CampaignAttachmentPreviewRule = { zip_filename?: string | null; matches: CampaignAttachmentPreviewFile[]; match_count: number; + linked_match_count?: number; + unlinked_match_count?: number; issues: Record[]; }; @@ -341,15 +345,37 @@ export type CampaignAttachmentPreviewResponse = { campaign_id: string; version_id: string; shared_file_count: number; + candidate_file_count?: number; + matched_file_count?: number; + linked_file_count?: number; + unlinked_file_count?: number; rules: CampaignAttachmentPreviewRule[]; + linkable_files?: CampaignAttachmentPreviewFile[]; unused_shared_files: CampaignAttachmentPreviewFile[]; }; export type CampaignAttachmentPreviewPayload = { include_unmatched?: boolean; + include_unlinked_candidates?: boolean; campaign_json?: Record; }; +export type CampaignAttachmentLinkMatchesPayload = { + campaign_json?: Record | null; + dry_run?: boolean; +}; + +export type CampaignAttachmentLinkMatchesResponse = { + campaign_id: string; + version_id: string; + matched_file_count: number; + already_linked_file_count: number; + linked_file_count: number; + dry_run?: boolean; + linked_files: CampaignAttachmentPreviewFile[]; + linkable_files: CampaignAttachmentPreviewFile[]; +}; + export type CampaignMockSendPayload = { version_id?: string | null; send?: boolean; @@ -365,6 +391,13 @@ export type CampaignReviewStatePayload = { reviewed_message_keys: string[]; }; +export type CampaignSendJobPayload = { + include_warnings?: boolean; + dry_run?: boolean; + use_rate_limit?: boolean; + enqueue_imap_task?: boolean; +}; + export type CampaignJobsQuery = { versionId?: string; @@ -702,11 +735,12 @@ versionId: string) export async function validateVersion( settings: ApiSettings, versionId: string, -checkFiles = false) +checkFiles = false, +linkUnsharedMatches = false) : Promise> { return apiFetch>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, { method: "POST", - body: JSON.stringify({ check_files: checkFiles }) + body: JSON.stringify({ check_files: checkFiles, link_unshared_matches: linkUnsharedMatches }) }); } @@ -735,6 +769,19 @@ payload: CampaignAttachmentPreviewPayload = {}) ); } +export function linkCampaignAttachmentMatches( +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignAttachmentLinkMatchesPayload = {}) +: Promise { + return apiFetch( + settings, + `/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/link-matches`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + export async function getCampaignSummary( settings: ApiSettings, campaignId: string, @@ -846,6 +893,18 @@ payload: Record = {}) }); } +export async function sendCampaignJob( +settings: ApiSettings, +campaignId: string, +jobId: string, +payload: CampaignSendJobPayload = {}) +: Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function resolveCampaignJobOutcome( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/campaigns/CampaignReportPage.tsx b/webui/src/features/campaigns/CampaignReportPage.tsx index f704f1b..80a915b 100644 --- a/webui/src/features/campaigns/CampaignReportPage.tsx +++ b/webui/src/features/campaigns/CampaignReportPage.tsx @@ -7,6 +7,7 @@ import { getCampaignJobsDelta, resolveCampaignJobOutcome, retryCampaignJobs, + sendCampaignJob, sendUnattemptedCampaignJobs, type CampaignJobDetailResponse, type CampaignJobsResponse } from @@ -155,7 +156,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: }, [loadJobs]); async function reloadAll() { - await Promise.all([reload(), loadJobs()]); + resetDeltaWatermark(jobsQueryKey); + jobPageCursorsRef.current = { 1: null }; + jobsRef.current = emptyCampaignJobsResponse(); + setJobs(emptyCampaignJobsResponse()); + await Promise.all([reload({ force: true }), loadJobs()]); } async function runExplicitAction(action: "retry" | "unattempted") { @@ -177,6 +182,67 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: } } + const failedRowsOnPage = useMemo( + () => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")), + [jobs.jobs] + ); + + async function retryFailedSynchronously(rows: Record[]) { + if (!version || busyAction || rows.length === 0) return; + setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page"); + setActionError(""); + setActionMessage(""); + let attempted = 0; + let accepted = 0; + let skipped = 0; + const failures: string[] = []; + try { + for (const row of rows) { + const jobId = String(row.id ?? ""); + if (!jobId) { + skipped += 1; + continue; + } + const sendStatus = String(row.send_status ?? ""); + const queueResponse = await retryCampaignJobs(settings, campaignId, { + version_id: version.id, + job_ids: [jobId], + include_permanent: sendStatus === "failed_permanent", + enqueue_celery: false + }); + const queueResult = asRecord(queueResponse.result ?? queueResponse); + if (Number(queueResult.selected_count ?? 0) < 1) { + skipped += 1; + const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : []; + const reason = String(skippedRows[0]?.reason ?? "not selected for retry"); + failures.push(`${shortJobId(jobId)}: ${reason}`); + continue; + } + attempted += 1; + try { + const sendResponse = await sendCampaignJob(settings, campaignId, jobId, { + include_warnings: true, + dry_run: false, + use_rate_limit: true, + enqueue_imap_task: false + }); + const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result); + const status = String(sendResult.status ?? "submitted"); + if (status === "smtp_accepted" || status === "already_accepted") accepted += 1; + else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`); + } catch (err) { + failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`); + } + } + const failed = failures.length; + setActionMessage(`Synchronous retry finished: ${attempted} attempted, ${accepted} accepted, ${failed} failed, ${skipped} skipped.`); + if (failures.length > 0) setActionError(failures.slice(0, 5).join("\n")); + await reloadAll(); + } finally { + setBusyAction(""); + } + } + async function reconcileOutcome() { if (!reconcile || busyAction) return; setBusyAction("reconcile"); @@ -300,13 +366,14 @@ export default function CampaignReportPage({ settings, campaignId }: {settings: return (
+ {retryableFailedStatus(status) && } {status === "outcome_unknown" && } {status === "outcome_unknown" && }
); } }], - [busyAction]); + [busyAction, retryFailedSynchronously]); return (
@@ -318,7 +385,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
- +
{(error || actionError) && {error || actionError}} @@ -350,6 +417,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:

i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80

+
@@ -492,3 +562,11 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record ); } + +function retryableFailedStatus(status: string): boolean { + return status === "failed_temporary" || status === "failed_permanent"; +} + +function shortJobId(jobId: string): string { + return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId; +}