feat(campaign-ui): add explicit job retry controls
This commit is contained in:
@@ -313,6 +313,8 @@ export type CampaignAttachmentPreviewFile = {
|
|||||||
checksum_sha256?: string;
|
checksum_sha256?: string;
|
||||||
size_bytes?: number;
|
size_bytes?: number;
|
||||||
content_type?: string | null;
|
content_type?: string | null;
|
||||||
|
linked_to_campaign?: boolean;
|
||||||
|
share?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignAttachmentPreviewRule = {
|
export type CampaignAttachmentPreviewRule = {
|
||||||
@@ -334,6 +336,8 @@ export type CampaignAttachmentPreviewRule = {
|
|||||||
zip_filename?: string | null;
|
zip_filename?: string | null;
|
||||||
matches: CampaignAttachmentPreviewFile[];
|
matches: CampaignAttachmentPreviewFile[];
|
||||||
match_count: number;
|
match_count: number;
|
||||||
|
linked_match_count?: number;
|
||||||
|
unlinked_match_count?: number;
|
||||||
issues: Record<string, unknown>[];
|
issues: Record<string, unknown>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,15 +345,37 @@ export type CampaignAttachmentPreviewResponse = {
|
|||||||
campaign_id: string;
|
campaign_id: string;
|
||||||
version_id: string;
|
version_id: string;
|
||||||
shared_file_count: number;
|
shared_file_count: number;
|
||||||
|
candidate_file_count?: number;
|
||||||
|
matched_file_count?: number;
|
||||||
|
linked_file_count?: number;
|
||||||
|
unlinked_file_count?: number;
|
||||||
rules: CampaignAttachmentPreviewRule[];
|
rules: CampaignAttachmentPreviewRule[];
|
||||||
|
linkable_files?: CampaignAttachmentPreviewFile[];
|
||||||
unused_shared_files: CampaignAttachmentPreviewFile[];
|
unused_shared_files: CampaignAttachmentPreviewFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignAttachmentPreviewPayload = {
|
export type CampaignAttachmentPreviewPayload = {
|
||||||
include_unmatched?: boolean;
|
include_unmatched?: boolean;
|
||||||
|
include_unlinked_candidates?: boolean;
|
||||||
campaign_json?: Record<string, unknown>;
|
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 = {
|
export type CampaignMockSendPayload = {
|
||||||
version_id?: string | null;
|
version_id?: string | null;
|
||||||
send?: boolean;
|
send?: boolean;
|
||||||
@@ -365,6 +391,13 @@ export type CampaignReviewStatePayload = {
|
|||||||
reviewed_message_keys: string[];
|
reviewed_message_keys: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignSendJobPayload = {
|
||||||
|
include_warnings?: boolean;
|
||||||
|
dry_run?: boolean;
|
||||||
|
use_rate_limit?: boolean;
|
||||||
|
enqueue_imap_task?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type CampaignJobsQuery = {
|
export type CampaignJobsQuery = {
|
||||||
versionId?: string;
|
versionId?: string;
|
||||||
@@ -702,11 +735,12 @@ versionId: string)
|
|||||||
export async function validateVersion(
|
export async function validateVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
checkFiles = false)
|
checkFiles = false,
|
||||||
|
linkUnsharedMatches = false)
|
||||||
: Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
||||||
method: "POST",
|
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(
|
export async function getCampaignSummary(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
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(
|
export async function resolveCampaignJobOutcome(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getCampaignJobsDelta,
|
getCampaignJobsDelta,
|
||||||
resolveCampaignJobOutcome,
|
resolveCampaignJobOutcome,
|
||||||
retryCampaignJobs,
|
retryCampaignJobs,
|
||||||
|
sendCampaignJob,
|
||||||
sendUnattemptedCampaignJobs,
|
sendUnattemptedCampaignJobs,
|
||||||
type CampaignJobDetailResponse,
|
type CampaignJobDetailResponse,
|
||||||
type CampaignJobsResponse } from
|
type CampaignJobsResponse } from
|
||||||
@@ -155,7 +156,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
}, [loadJobs]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
async function reloadAll() {
|
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") {
|
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() {
|
async function reconcileOutcome() {
|
||||||
if (!reconcile || busyAction) return;
|
if (!reconcile || busyAction) return;
|
||||||
setBusyAction("reconcile");
|
setBusyAction("reconcile");
|
||||||
@@ -300,13 +366,14 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
return (
|
return (
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
<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: "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>}
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
[busyAction]);
|
[busyAction, retryFailedSynchronously]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
@@ -318,7 +385,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
<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={() => 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>
|
||||||
</div>
|
</div>
|
||||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
{(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>
|
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||||
<div className="button-row compact-actions">
|
<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 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>
|
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -492,3 +562,11 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
|||||||
</section>);
|
</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