initial commit after split
This commit is contained in:
357
webui/src/features/campaigns/CampaignReportPage.tsx
Normal file
357
webui/src/features/campaigns/CampaignReportPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
downloadCampaignJobsCsv,
|
||||
emailCampaignReport,
|
||||
getCampaignJobDetail,
|
||||
getCampaignJobs,
|
||||
resolveCampaignJobOutcome,
|
||||
retryCampaignJobs,
|
||||
sendUnattemptedCampaignJobs,
|
||||
type CampaignJobDetailResponse,
|
||||
type CampaignJobsResponse,
|
||||
} from "../../api/campaigns";
|
||||
import Card from "../../components/Card";
|
||||
import Button from "../../components/Button";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
|
||||
|
||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_queued",
|
||||
"queued",
|
||||
"claimed",
|
||||
"sending",
|
||||
"smtp_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"cancelled",
|
||||
].map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
type ReconcileRequest = { jobId: string; decision: "smtp_accepted" | "not_sent" } | null;
|
||||
|
||||
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const version = data.currentVersion;
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = asRecord(data.summary?.delivery);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>({
|
||||
jobs: [],
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total: 0,
|
||||
total_unfiltered: 0,
|
||||
pages: 0,
|
||||
counts: {},
|
||||
filtered_counts: {},
|
||||
review: {},
|
||||
});
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sendStatus, setSendStatus] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [busyAction, setBusyAction] = useState("");
|
||||
const [detail, setDetail] = useState<CampaignJobDetailResponse | null>(null);
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
const [emailRecipients, setEmailRecipients] = useState("");
|
||||
const [attachCsv, setAttachCsv] = useState(true);
|
||||
const [attachJson, setAttachJson] = useState(false);
|
||||
const [reconcile, setReconcile] = useState<ReconcileRequest>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
setAppliedQuery(query.trim());
|
||||
setPage(1);
|
||||
}, 350);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
|
||||
|
||||
async function loadJobs() {
|
||||
if (!campaignId) return;
|
||||
setJobsLoading(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const response = await getCampaignJobs(settings, campaignId, {
|
||||
versionId: version?.id,
|
||||
page,
|
||||
pageSize: 50,
|
||||
sendStatus: sendStatus ? [sendStatus] : undefined,
|
||||
query: appliedQuery || undefined,
|
||||
});
|
||||
setJobs(response);
|
||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadAll() {
|
||||
await Promise.all([reload(), loadJobs()]);
|
||||
}
|
||||
|
||||
async function runExplicitAction(action: "retry" | "unattempted") {
|
||||
if (!version || busyAction) return;
|
||||
setBusyAction(action);
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
try {
|
||||
const response = action === "retry"
|
||||
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true })
|
||||
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||
const result = asRecord(response.result ?? response);
|
||||
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileOutcome() {
|
||||
if (!reconcile || busyAction) return;
|
||||
setBusyAction("reconcile");
|
||||
setActionError("");
|
||||
try {
|
||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
||||
setActionMessage(reconcile.decision === "smtp_accepted"
|
||||
? "The job was recorded as SMTP accepted and is protected from retry."
|
||||
: "The job was recorded as not sent. It is now an explicit retry candidate.");
|
||||
setReconcile(null);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function openJob(jobId: string) {
|
||||
setBusyAction("detail");
|
||||
setActionError("");
|
||||
try {
|
||||
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
setBusyAction("csv");
|
||||
setActionError("");
|
||||
try {
|
||||
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
|
||||
setActionMessage("Campaign job CSV downloaded.");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReportEmail() {
|
||||
const recipients = emailRecipients.split(/[;,\n]+/).map((value) => value.trim()).filter(Boolean);
|
||||
if (recipients.length === 0 || busyAction) return;
|
||||
setBusyAction("email");
|
||||
setActionError("");
|
||||
try {
|
||||
await emailCampaignReport(settings, campaignId, {
|
||||
to: recipients,
|
||||
version_id: version?.id,
|
||||
include_jobs: false,
|
||||
attach_jobs_csv: attachCsv,
|
||||
attach_report_json: attachJson,
|
||||
});
|
||||
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
||||
setEmailOpen(false);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyAction("");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
|
||||
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) + 1 },
|
||||
{ id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") },
|
||||
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
{ id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
||||
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 250,
|
||||
sticky: "end",
|
||||
render: (row) => {
|
||||
const id = String(row.id ?? "");
|
||||
const status = String(row.send_status ?? "");
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button>
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [busyAction]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Report</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>Email report</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
||||
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading report data…">
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Delivery outcome">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="IMAP and execution plan">
|
||||
<dl className="detail-list">
|
||||
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>IMAP failed</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||
<div><dt>Append policy</dt><dd>{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}</dd></div>
|
||||
<div><dt>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div>
|
||||
<div><dt>Minimum remaining duration</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||
<div><dt>Execution snapshot</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}…` : "Missing"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Explicit delivery actions">
|
||||
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button>
|
||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Recipient delivery jobs">
|
||||
<div className="page-heading split">
|
||||
<div className="button-row compact-actions">
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" />
|
||||
<select value={sendStatus} onChange={(event) => { setSendStatus(event.target.value); setPage(1); }}>
|
||||
<option value="">All SMTP states</option>
|
||||
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<span className="muted">{jobs.total} matching of {jobs.total_unfiltered} total job(s)</span>
|
||||
</div>
|
||||
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs…">
|
||||
<DataGrid<Record<string, unknown>>
|
||||
id={`campaign-report-jobs-${campaignId}`}
|
||||
rows={jobs.jobs}
|
||||
columns={columns}
|
||||
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
|
||||
emptyText="No jobs match the current filters."
|
||||
/>
|
||||
</LoadingFrame>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button>
|
||||
<span>Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
||||
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>Next</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<Dialog
|
||||
open={emailOpen}
|
||||
title="Email campaign report"
|
||||
onClose={() => setEmailOpen(false)}
|
||||
closeDisabled={busyAction === "email"}
|
||||
footer={(
|
||||
<div className="button-row">
|
||||
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field-stack">
|
||||
<span>Recipients</span>
|
||||
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
|
||||
</label>
|
||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label>
|
||||
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</label>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(detail)}
|
||||
title="Campaign job detail"
|
||||
onClose={() => setDetail(null)}
|
||||
className="dialog-panel-wide"
|
||||
>
|
||||
{detail && (
|
||||
<div className="stacked-sections">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||
<div><dt>Subject</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||
<div><dt>SMTP state</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>Attachments</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||
</dl>
|
||||
<h3>SMTP attempts</h3>
|
||||
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre>
|
||||
<h3>IMAP attempts</h3>
|
||||
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(reconcile)}
|
||||
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"}
|
||||
message={reconcile?.decision === "smtp_accepted"
|
||||
? "Use this only after checking the SMTP server or recipient-side evidence. The job will be protected from retry and may proceed to IMAP append."
|
||||
: "Use this only when you have evidence that SMTP did not accept the message. The job will become a failed-temporary retry candidate, but it will not be retried automatically."}
|
||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record not sent"}
|
||||
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
|
||||
busy={busyAction === "reconcile"}
|
||||
onConfirm={() => void reconcileOutcome()}
|
||||
onCancel={() => setReconcile(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user