initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

View 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>
);
}