feat(campaign-ui): add explicit job retry controls
This commit is contained in:
@@ -313,6 +313,8 @@ export type CampaignAttachmentPreviewFile = {
|
||||
checksum_sha256?: string;
|
||||
size_bytes?: number;
|
||||
content_type?: string | null;
|
||||
linked_to_campaign?: boolean;
|
||||
share?: Record<string, unknown> | 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<string, unknown>[];
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignAttachmentLinkMatchesPayload = {
|
||||
campaign_json?: Record<string, unknown> | 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<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(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<CampaignAttachmentLinkMatchesResponse> {
|
||||
return apiFetch<CampaignAttachmentLinkMatchesResponse>(
|
||||
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<string, unknown> = {})
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendCampaignJob(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
payload: CampaignSendJobPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveCampaignJobOutcome(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -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<string, unknown>[]) {
|
||||
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 (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
||||
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
}],
|
||||
[busyAction]);
|
||||
[busyAction, retryFailedSynchronously]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
@@ -318,7 +385,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Discard</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
||||
@@ -350,6 +417,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
||||
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
||||
</Button>
|
||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -492,3 +562,11 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user