feat(campaign): complete durable operator queue

This commit is contained in:
2026-07-22 09:31:33 +02:00
parent f095a3e2c7
commit 99d44eeb8d
5 changed files with 832 additions and 81 deletions
+644 -67
View File
@@ -1,20 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Ban, ExternalLink, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, CampaignListItem } from "../../types";
import {
cancelCampaign,
getCampaignJobs,
getCampaignSummary,
getCampaignWorkspaceDelta,
listCampaignsDelta,
pauseCampaign,
resumeCampaign,
retryCampaignJobs,
sendUnattemptedCampaignJobs,
type CampaignSummary
type CampaignJobsResponse,
type CampaignSummary,
type CampaignVersionListItem
} from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import {
DataGrid,
type DataGridColumn,
type DataGridListOption,
type DataGridQueryState
} from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
@@ -31,7 +41,11 @@ import {
} from "@govoplan/core-webui";
import { asRecord, formatDateTime } from "../campaigns/utils/campaignView";
import { deliveryModeLabel } from "../campaigns/utils/deliveryMode";
import { emptyCampaignJobsResponse } from "../campaigns/utils/jobDeltas";
import type { CampaignJobSortColumn } from "../campaigns/utils/jobListQuery";
import {
campaignLifecycleTotals,
campaignVersionWorkItems,
operatorQueueActionBlocks,
type OperatorQueueAction,
type OperatorQueuePermissions
@@ -39,6 +53,8 @@ import {
type OperatorRow = {
campaign: CampaignListItem;
version: CampaignVersionListItem | null;
isCurrent: boolean;
summary: CampaignSummary | null;
mode: string | null;
modeSelectedAt: string | null;
@@ -48,22 +64,64 @@ type OperatorRow = {
notAttempted: number;
queuedOrActive: number;
queued: number;
claimed: number;
sending: number;
completed: number;
pausable: number;
paused: number;
campaignPausable: number;
campaignPaused: number;
imapFailed: number;
queueable: number;
queueableUnattempted: number;
needsAttention: number;
cancellable: number;
campaignCancellable: number;
};
type QueueMutationAction = "pause" | "resume" | "retry" | "queue-unsent" | "cancel";
type CampaignLifecycleFacts = {pausable: number;paused: number;cancellable: number;};
const DEFAULT_JOB_GRID_QUERY: DataGridQueryState = {
sort: { columnId: "number", direction: "asc" },
filters: {}
};
const QUEUE_STATUS_OPTIONS: DataGridListOption[] = ["draft", "queued", "sending", "paused", "cancelled"]
.map((value) => ({ value, label: deliveryStatusOptionLabel(value) }));
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued",
"skipped",
"queued",
"claimed",
"sending",
"smtp_accepted",
"sent",
"outcome_unknown",
"failed_temporary",
"failed_permanent",
"cancelled"
].map((value) => ({ value, label: deliveryStatusOptionLabel(value) }));
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"appending",
"appended",
"outcome_unknown",
"failed",
"skipped"
].map((value) => ({ value, label: deliveryStatusOptionLabel(value) }));
export default function OperatorQueuePage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const navigate = useGuardedNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const selectedCampaignId = searchParams.get("campaign") ?? "";
const selectedVersionParam = searchParams.get("version") ?? "";
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [rows, setRows] = useState<OperatorRow[]>([]);
const campaignsRef = useRef<CampaignListItem[]>([]);
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
const summariesRef = useRef<Record<string, Record<string, CampaignSummary | null>>>({});
const versionsRef = useRef<Record<string, CampaignVersionListItem[]>>({});
const loadingRef = useRef(false);
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
const [loading, setLoading] = useState(true);
@@ -71,9 +129,20 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
const [cancelTarget, setCancelTarget] = useState<OperatorRow | null>(null);
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const [jobsOwnerKey, setJobsOwnerKey] = useState("");
const [jobsLoading, setJobsLoading] = useState(false);
const [jobsError, setJobsError] = useState("");
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(50);
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(DEFAULT_JOB_GRID_QUERY);
const jobsRequestRef = useRef(0);
const backgroundJobsLoadRef = useRef<() => Promise<void>>(async () => undefined);
const permissions = useMemo<OperatorQueuePermissions>(() => ({
canRead: hasScope(auth, "campaigns:campaign:read")
&& hasScope(auth, "campaigns:recipient:read"),
canOpenReport: hasScope(auth, "campaigns:campaign:read")
&& hasScope(auth, "campaigns:report:read")
&& hasScope(auth, "campaigns:recipient:read"),
canControl: hasScope(auth, "campaigns:campaign:control"),
@@ -94,26 +163,95 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}),
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
const selectedRow = useMemo(
() => rows.find((row) => row.campaign.id === selectedCampaignId && (
selectedVersionParam
? rowVersionId(row) === selectedVersionParam
: rowVersionId(row) === String(row.campaign.current_version_id ?? "")
)) ?? rows.find((row) => row.campaign.id === selectedCampaignId) ?? null,
[rows, selectedCampaignId, selectedVersionParam]
);
const selectedVersionId = rowVersionId(selectedRow);
const selectedJobsKey = selectedRow && selectedVersionId
? `${selectedRow.campaign.id}:${selectedVersionId}:${settingsKey}`
: "";
const selectCampaign = useCallback((campaignId: string, versionId = "") => {
const next = new URLSearchParams(searchParams);
if (campaignId) {
next.set("campaign", campaignId);
if (versionId) next.set("version", versionId);
else next.delete("version");
} else {
next.delete("campaign");
next.delete("version");
}
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const handleJobGridQuery = useCallback((next: DataGridQueryState) => {
setJobGridQuery((current) => dataGridQueriesEqual(current, next) ? current : next);
setJobPage(1);
}, []);
useEffect(() => {
campaignsRef.current = [];
summariesRef.current = {};
versionsRef.current = {};
resetDeltaWatermark();
void load();
}, [settingsKey, resetDeltaWatermark]);
useEffect(() => {
backgroundLoadRef.current = () => load(true);
backgroundJobsLoadRef.current = () => loadSelectedJobs(true);
});
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0);
useEffect(() => {
if (!hasActiveDelivery) return;
if (!loading && selectedCampaignId && !selectedRow) selectCampaign("");
}, [loading, selectCampaign, selectedCampaignId, selectedRow]);
useEffect(() => {
if (!loading && selectedRow && selectedVersionParam !== rowVersionId(selectedRow)) {
selectCampaign(selectedRow.campaign.id, rowVersionId(selectedRow));
}
}, [loading, selectCampaign, selectedRow, selectedVersionParam]);
useEffect(() => {
jobsRequestRef.current += 1;
setJobs(emptyCampaignJobsResponse());
setJobsLoading(false);
setJobsError("");
setJobPage(1);
setJobGridQuery(DEFAULT_JOB_GRID_QUERY);
setJobsOwnerKey(selectedJobsKey);
}, [selectedJobsKey]);
const jobGridQueryKey = JSON.stringify(jobGridQuery);
useEffect(() => {
if (jobsOwnerKey === selectedJobsKey && selectedJobsKey) void loadSelectedJobs();
}, [jobsOwnerKey, selectedJobsKey, jobPage, jobPageSize, jobGridQueryKey, permissions.canRead]);
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0 || row.needsAttention > 0);
const selectedJobsActive = jobs.jobs.some((job) => isActiveJob(job));
const selectedQueueActive = Boolean(
selectedRow && (selectedRow.queuedOrActive > 0 || selectedRow.needsAttention > 0)
) || selectedJobsActive;
useEffect(() => {
if (!hasActiveDelivery && !selectedQueueActive) return;
const handle = window.setInterval(() => {
void backgroundLoadRef.current();
if (selectedQueueActive) void backgroundJobsLoadRef.current();
}, 10_000);
return () => window.clearInterval(handle);
}, [hasActiveDelivery]);
}, [hasActiveDelivery, selectedQueueActive]);
useEffect(() => {
const handle = window.setInterval(() => {
void backgroundLoadRef.current();
}, 60_000);
return () => window.clearInterval(handle);
}, []);
async function load(background = false) {
if (loadingRef.current) return;
@@ -122,8 +260,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
setError("");
try {
const campaigns = await loadCampaignsDelta();
const summaries = await loadCampaignSummariesDelta(campaigns);
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
setRows(await loadCampaignVersionRows(campaigns));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -132,6 +269,37 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
}
async function loadSelectedJobs(background = false) {
if (!selectedRow || !selectedVersionId || !permissions.canRead || jobsOwnerKey !== selectedJobsKey) return;
const requestId = ++jobsRequestRef.current;
if (!background) setJobsLoading(true);
setJobsError("");
try {
const response = await getCampaignJobs(settings, selectedRow.campaign.id, {
versionId: selectedVersionId,
page: jobPage,
pageSize: jobPageSize,
sortBy: campaignJobSortColumn(jobGridQuery.sort?.columnId),
sortDirection: jobGridQuery.sort?.direction ?? "asc",
filters: jobGridQuery.filters
});
if (requestId !== jobsRequestRef.current) return;
setJobs(response);
if (response.pages > 0 && jobPage > response.pages) setJobPage(response.pages);
} catch (err) {
if (requestId === jobsRequestRef.current) {
setJobsError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!background && requestId === jobsRequestRef.current) setJobsLoading(false);
}
}
async function refreshAll() {
await load();
await backgroundJobsLoadRef.current();
}
async function runAction(row: OperatorRow, action: QueueMutationAction) {
const key = `${action}:${row.campaign.id}`;
setBusy(key);
@@ -139,9 +307,15 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
setMessage("");
try {
const response = action === "retry"
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
? await retryCampaignJobs(settings, row.campaign.id, {
version_id: rowVersionId(row),
enqueue_celery: true
})
: action === "queue-unsent"
? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
? await sendUnattemptedCampaignJobs(settings, row.campaign.id, {
version_id: rowVersionId(row),
enqueue_celery: true
})
: action === "pause"
? await pauseCampaign(settings, row.campaign.id)
: action === "resume"
@@ -157,8 +331,8 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
));
if (action === "cancel") setCancelTarget(null);
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
await load();
resetDeltaWatermark(operatorCampaignWorkspaceKey(row.campaign.id));
await refreshAll();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -189,92 +363,190 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
return campaigns;
}
async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
async function loadCampaignVersionRows(campaigns: CampaignListItem[]): Promise<OperatorRow[]> {
const summaries = { ...summariesRef.current };
const versions = { ...versionsRef.current };
await Promise.all(campaigns.map(async (campaign) => {
const key = operatorCampaignSummaryKey(campaign.id);
const key = operatorCampaignWorkspaceKey(campaign.id);
let nextWatermark = getDeltaWatermark(key);
let summary = summaries[campaign.id] ?? null;
let campaignVersions = versions[campaign.id] ?? [];
let hasMore = false;
try {
do {
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
includeCurrentVersion: false,
includeVersions: false,
includeSummary: true,
includeVersions: true,
includeSummary: false,
since: nextWatermark
});
if (response.full || response.summary) summary = response.summary;
campaignVersions = response.full
? response.versions
: mergeDeltaRows(
campaignVersions,
response.versions,
response.deleted,
(version) => version.id,
{
deletedResourceType: "campaign_version",
sort: sortVersionsNewestFirst
}
);
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
summaries[campaign.id] = summary;
versions[campaign.id] = campaignVersions;
const campaignSummaries = { ...(summaries[campaign.id] ?? {}) };
await Promise.all(campaignVersions.map(async (version) => {
try {
campaignSummaries[version.id] = await getCampaignSummary(settings, campaign.id, version.id);
} catch {
campaignSummaries[version.id] = campaignSummaries[version.id] ?? null;
}
}));
const versionIds = new Set(campaignVersions.map((version) => version.id));
for (const versionId of Object.keys(campaignSummaries)) {
if (!versionIds.has(versionId)) delete campaignSummaries[versionId];
}
summaries[campaign.id] = campaignSummaries;
} catch {
summaries[campaign.id] = summary;
versions[campaign.id] = campaignVersions;
}
}));
const campaignIds = new Set(campaigns.map((campaign) => campaign.id));
for (const campaignId of Object.keys(versions)) {
if (!campaignIds.has(campaignId)) {
delete versions[campaignId];
delete summaries[campaignId];
}
}
versionsRef.current = versions;
summariesRef.current = summaries;
return summaries;
return campaigns.flatMap((campaign) => toVersionRows(
campaign,
versions[campaign.id] ?? [],
summaries[campaign.id] ?? {}
));
}
function operatorCampaignListKey(): string {
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
}
function operatorCampaignSummaryKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-summary", campaignId, settingsKey });
function operatorCampaignWorkspaceKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-workspace", campaignId, settingsKey });
}
const totals = rows.reduce((acc, row) => ({
failed: acc.failed + row.failed,
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
notAttempted: acc.notAttempted + row.notAttempted,
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
queued: acc.queued + row.queued,
claimed: acc.claimed + row.claimed,
sending: acc.sending + row.sending,
completed: acc.completed + row.completed,
paused: acc.paused + row.paused,
imapFailed: acc.imapFailed + row.imapFailed
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, paused: 0, imapFailed: 0 });
}), {
failed: 0,
outcomeUnknown: 0,
notAttempted: 0,
queued: 0,
claimed: 0,
sending: 0,
completed: 0,
paused: 0,
imapFailed: 0
});
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
{ id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
{
id: "version",
header: "i18n:govoplan-campaign.version.2da600bf",
width: 155,
sortable: true,
render: (row) => (
<div className="recipient-outcome-cell">
<strong>{campaignVersionIdentity(row)}</strong>
<span><StatusBadge status={row.isCurrent ? "current" : "historical"} label={row.isCurrent
? "i18n:govoplan-campaign.current.4fc0e2bc"
: "i18n:govoplan-campaign.historical.bb2fe205"} /></span>
</div>
),
value: (row) => campaignVersionIdentity(row),
sortValue: (row) => row.version?.version_number ?? row.summary?.current_version?.version_number ?? -1
},
{ id: "mode", header: "i18n:govoplan-campaign.mode.a7b93d21", width: 155, sortable: true, filterable: true, value: (row) => deliveryModeLabel(row.mode), sortValue: (row) => row.mode ?? "" },
{ id: "attention", header: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
{ id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
{ id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
{ id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
{ id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
{ id: "queued", header: "i18n:govoplan-campaign.queued.6a599877", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.queued },
{ id: "claimed", header: "i18n:govoplan-campaign.claimed.83c87884", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.claimed },
{ id: "sending", header: "i18n:govoplan-campaign.sending.ceafde86", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.sending },
{ id: "completed", header: "i18n:govoplan-campaign.completed.1798b3ba", width: 115, align: "right", sortable: true, filterType: "integer", value: (row) => row.completed },
{ id: "paused", header: "i18n:govoplan-campaign.paused.c7dfb6f1", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.paused },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.campaign.updated_at ?? "" },
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at ?? "" },
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 330,
sticky: "end",
render: (row) => {
const blocks = operatorQueueActionBlocks(row, permissions);
const blocks = operatorQueueActionBlocks({
retryable: row.retryable,
outcomeUnknown: row.outcomeUnknown,
queueableUnattempted: row.queueableUnattempted,
pausable: row.campaignPausable,
paused: row.campaignPaused,
cancellable: row.campaignCancellable
}, permissions);
const disabledReason = (action: OperatorQueueAction) =>
blocks[action] ?? (busy && action !== "details"
? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea"
: undefined);
const detailsDisabledReason = disabledReason("details")
?? (!rowVersionId(row)
? "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b"
: row.campaign.id === selectedCampaignId && rowVersionId(row) === selectedVersionParam
? "i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8"
: undefined);
const reportDisabledReason = disabledReason("report")
?? (!rowVersionId(row)
? "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b"
: undefined);
return <TableActionGroup actions={[
{
id: "details",
label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }),
icon: <Eye aria-hidden="true" />,
variant: row.campaign.id === selectedCampaignId && rowVersionId(row) === selectedVersionParam ? "primary" : "secondary",
disabledReason: detailsDisabledReason,
onClick: () => selectCampaign(row.campaign.id, rowVersionId(row))
},
{
id: "report",
label: "i18n:govoplan-campaign.details.dc3decbb",
icon: <ExternalLink aria-hidden="true" />,
disabledReason: disabledReason("details"),
onClick: () => navigate(`/campaigns/${row.campaign.id}/report`)
disabledReason: reportDisabledReason,
onClick: () => {
const versionId = rowVersionId(row);
if (versionId) navigate(`/campaigns/${row.campaign.id}/report?version=${encodeURIComponent(versionId)}`);
}
},
{
id: "pause",
label: "i18n:govoplan-campaign.pause_queue.b66e7696",
label: "i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf",
icon: <Pause aria-hidden="true" />,
disabledReason: disabledReason("pause"),
onClick: () => void runAction(row, "pause")
},
{
id: "resume",
label: "i18n:govoplan-campaign.resume_queue.1f22f964",
label: "i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a",
icon: <Play aria-hidden="true" />,
disabledReason: disabledReason("resume"),
onClick: () => void runAction(row, "resume")
@@ -298,11 +570,11 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
label: "i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7",
icon: <ShieldCheck aria-hidden="true" />,
disabledReason: disabledReason("reconcile"),
onClick: () => navigate(`/campaigns/${row.campaign.id}/report?send_status=outcome_unknown`)
onClick: () => navigate(`/campaigns/${row.campaign.id}/report?version=${encodeURIComponent(rowVersionId(row))}&send_status=outcome_unknown`)
},
{
id: "cancel",
label: "i18n:govoplan-campaign.cancel_queued_work.a54fb955",
label: "i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b",
icon: <Ban aria-hidden="true" />,
variant: "danger",
disabledReason: disabledReason("cancel"),
@@ -312,7 +584,135 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
}],
[busy, navigate, permissions]);
[busy, navigate, permissions, selectCampaign, selectedCampaignId, selectedVersionParam]);
const jobColumns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
{
id: "number",
header: "i18n:govoplan-campaign.job.30c8cb83",
width: 150,
sticky: "start",
sortable: true,
render: (job) => (
<div className="recipient-outcome-cell">
<strong>{shortSafeId(String(job.id ?? ""))}</strong>
<span>{jobEntryIdentity(job)}</span>
</div>
),
value: (job) => Number(job.entry_index ?? 0)
},
{
id: "version",
header: "i18n:govoplan-campaign.version.2da600bf",
width: 145,
render: (job) => jobVersionIdentity(job, selectedRow),
value: (job) => jobVersionIdentity(job, selectedRow)
},
{
id: "recipient",
header: "i18n:govoplan-campaign.recipient.90343260",
width: 240,
resizable: true,
sortable: true,
filterable: true,
value: (job) => String(job.recipient_email ?? "—")
},
{
id: "queue",
header: "i18n:govoplan-campaign.queue.d325fcd9",
width: 125,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: QUEUE_STATUS_OPTIONS, display: "pill" },
render: (job) => <StatusBadge status={String(job.queue_status ?? "unknown")} label={deliveryStatusOptionLabel(String(job.queue_status ?? "unknown"))} />,
value: (job) => String(job.queue_status ?? "unknown")
},
{
id: "send",
header: "i18n:govoplan-campaign.smtp.efff9cca",
width: 150,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: SEND_STATUS_OPTIONS, display: "pill" },
render: (job) => <StatusBadge status={String(job.send_status ?? "unknown")} label={deliveryStatusOptionLabel(String(job.send_status ?? "unknown"))} />,
value: (job) => String(job.send_status ?? "unknown")
},
{
id: "imap",
header: "i18n:govoplan-campaign.imap.271f9ef2",
width: 135,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: IMAP_STATUS_OPTIONS, display: "pill" },
render: (job) => <StatusBadge status={String(job.imap_status ?? "unknown")} label={deliveryStatusOptionLabel(String(job.imap_status ?? "unknown"))} />,
value: (job) => String(job.imap_status ?? "unknown")
},
{
id: "attempts",
header: "i18n:govoplan-campaign.attempts.5a29585e",
width: 105,
align: "right",
sortable: true,
filterable: true,
filterType: "integer",
value: (job) => Number(job.attempt_count ?? 0)
},
{
id: "queued-at",
header: "i18n:govoplan-campaign.queued_at.1ac179b3",
width: 175,
render: (job) => formatDateTime(String(job.queued_at ?? "")),
value: (job) => String(job.queued_at ?? "")
},
{
id: "sent-at",
header: "i18n:govoplan-campaign.sent_at.a6f45556",
width: 175,
render: (job) => formatDateTime(String(job.sent_at ?? "")),
value: (job) => String(job.sent_at ?? "")
},
{
id: "updated",
header: "i18n:govoplan-campaign.updated.f2f8570d",
width: 175,
sortable: true,
render: (job) => formatDateTime(String(job.updated_at ?? "")),
value: (job) => String(job.updated_at ?? "")
},
{
id: "result",
header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)",
minWidth: 200,
resizable: true,
render: (job) => {
const result = durableResultText(job.last_error) ?? deliveryResultSummary(job);
return <span className={result === "—" ? "muted" : "recipient-outcome-error"}>{result}</span>;
},
value: (job) => durableResultText(job.last_error) ?? deliveryResultSummary(job)
},
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 72,
sticky: "end",
align: "right",
render: (job) => <TableActionGroup actions={[{
id: "open-recipient-report",
label: "i18n:govoplan-campaign.open_recipient_report.d6892ee4",
icon: <ExternalLink aria-hidden="true" />,
disabledReason: permissions.canOpenReport
? undefined
: "i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6",
onClick: () => {
if (selectedRow && selectedVersionId) navigate(recipientReportPath(selectedRow, selectedVersionId, job));
}
}]} />
}
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
return (
<div className="content-pad workspace-data-page">
@@ -323,23 +723,27 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
<p className="muted small-note">i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
<Button onClick={() => void refreshAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{jobsError && <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
<QueuePressureGrid items={[
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
{ label: "i18n:govoplan-campaign.paused.c7dfb6f1", value: totals.paused, tone: "neutral" },
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
} />
<div className="metric-grid queue-pressure-grid">
<MetricCard label="i18n:govoplan-campaign.queued.6a599877" value={totals.queued} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.claimed.83c87884" value={totals.claimed} tone="info" />
<MetricCard label="i18n:govoplan-campaign.sending.ceafde86" value={totals.sending} tone="info" />
<MetricCard label="i18n:govoplan-campaign.completed.1798b3ba" value={totals.completed} tone="good" />
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={totals.failed} tone="danger" />
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={totals.outcomeUnknown} tone="warning" />
<MetricCard label="i18n:govoplan-campaign.unattempted.e7411dd6" value={totals.notAttempted} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.paused.c7dfb6f1" value={totals.paused} tone="neutral" />
<MetricCard label="i18n:govoplan-campaign.imap_failed.50dbca55" value={totals.imapFailed} tone="warning" />
</div>
</section>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
@@ -348,17 +752,52 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
id="operator-queue-campaigns"
rows={rows}
columns={columns}
getRowKey={(row) => row.campaign.id}
getRowKey={(row) => `${row.campaign.id}:${rowVersionId(row) || "none"}`}
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
className="data-table compact-table" />
</Card>
</LoadingFrame>
{selectedRow && (
<Card title={i18nMessage("i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70", { value0: selectedRow.campaign.name })}>
<p className="muted small-note">{campaignVersionIdentity(selectedRow)}</p>
{!permissions.canRead ? (
<p>i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6</p>
) : (
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
<DataGrid<Record<string, unknown>>
id={`operator-queue-jobs-${selectedRow.campaign.id}-${selectedVersionId ?? "none"}`}
rows={jobs.jobs}
columns={jobColumns}
getRowKey={(job) => String(job.id ?? "")}
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
query={jobGridQuery}
pagination={{
mode: "server",
page: jobPage,
pageSize: jobPageSize,
totalRows: jobs.total,
pageSizeOptions: [25, 50, 100, 200],
disabled: jobsLoading,
onPageChange: setJobPage,
onPageSizeChange: (nextPageSize) => {
setJobPageSize(nextPageSize);
setJobPage(1);
}
}}
onQueryChange={handleJobGridQuery}
/>
</LoadingFrame>
)}
</Card>
)}
<ConfirmDialog
open={Boolean(cancelTarget)}
title="i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e"
message="i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb"
confirmLabel="i18n:govoplan-campaign.cancel_queued_work.a54fb955"
message="i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34"
confirmLabel="i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b"
tone="danger"
busy={Boolean(busy)}
onConfirm={() => {
@@ -369,31 +808,77 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
}
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
function toVersionRows(
campaign: CampaignListItem,
versions: CampaignVersionListItem[],
summaries: Record<string, CampaignSummary | null>
): OperatorRow[] {
if (versions.length === 0) return [toRow(campaign, null, false, null, { pausable: 0, paused: 0, cancellable: 0 })];
const lifecycle = campaignLifecycleTotals(versions.map((version) => {
const summary = summaries[version.id];
const cards = asRecord(summary?.cards);
const queueCounts = asRecord(asRecord(summary?.status_counts).queue);
return {
pausable: numberValue(queueCounts.queued),
paused: numberValue(queueCounts.paused),
cancellable: numberValue(cards.cancellable)
};
}));
const workItems = campaignVersionWorkItems(
campaign.id,
campaign.current_version_id,
versions,
summaries
);
return workItems.map((item) => {
const version = versions.find((candidate) => candidate.id === item.versionId) ?? null;
return toRow(campaign, version, item.isCurrent, item.summary, lifecycle);
});
}
function toRow(
campaign: CampaignListItem,
version: CampaignVersionListItem | null,
isCurrent: boolean,
summary: CampaignSummary | null,
lifecycle: CampaignLifecycleFacts
): OperatorRow {
const cards = asRecord(summary?.cards);
const statusCounts = asRecord(summary?.status_counts);
const queueCounts = asRecord(statusCounts.queue);
const queued = numberValue(queueCounts.queued);
const paused = numberValue(queueCounts.paused);
const sendCounts = asRecord(statusCounts.send);
const queued = numberValue(sendCounts.queued);
const claimed = numberValue(sendCounts.claimed);
const sending = numberValue(sendCounts.sending);
const completed = numberValue(sendCounts.smtp_accepted) + numberValue(sendCounts.sent);
const failed = numberValue(cards.failed);
const notAttempted = numberValue(cards.not_attempted);
return {
campaign,
version,
isCurrent,
summary,
mode: summary?.current_version?.delivery_mode ?? null,
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? null,
mode: summary?.current_version?.delivery_mode ?? version?.delivery_mode ?? null,
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? version?.delivery_mode_selected_at ?? null,
failed,
retryable: numberValue(cards.retryable),
outcomeUnknown: numberValue(cards.outcome_unknown),
notAttempted,
queuedOrActive: numberValue(cards.queued_or_active),
queuedOrActive: queued + claimed + sending,
queued,
paused,
claimed,
sending,
completed,
pausable: numberValue(queueCounts.queued),
paused: numberValue(queueCounts.paused),
campaignPausable: lifecycle.pausable,
campaignPaused: lifecycle.paused,
imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable),
queueableUnattempted: numberValue(cards.queueable_unattempted),
needsAttention: numberValue(cards.needs_attention),
cancellable: numberValue(cards.cancellable)
cancellable: numberValue(cards.cancellable),
campaignCancellable: lifecycle.cancellable
};
}
@@ -402,11 +887,11 @@ function numberValue(value: unknown): number {
}
function actionLabel(action: QueueMutationAction): string {
if (action === "pause") return "i18n:govoplan-campaign.pause_queue.b66e7696";
if (action === "resume") return "i18n:govoplan-campaign.resume_queue.1f22f964";
if (action === "pause") return "i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf";
if (action === "resume") return "i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a";
if (action === "retry") return "i18n:govoplan-campaign.retry.9f5cd8a2";
if (action === "queue-unsent") return "i18n:govoplan-campaign.queue_unsent.b0e98610";
return "i18n:govoplan-campaign.cancel_queued_work.a54fb955";
return "i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b";
}
function actionChangedCount(action: QueueMutationAction, result: Record<string, unknown>): number {
@@ -422,12 +907,104 @@ function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListI
);
}
function QueuePressureGrid({ items }: {items: Array<{label: string;value: number;tone: "neutral" | "good" | "warning" | "danger" | "info";}>;}) {
return (
<div className="metric-grid queue-pressure-grid">
{items.map((item) =>
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
)}
</div>);
function sortVersionsNewestFirst(left: CampaignVersionListItem, right: CampaignVersionListItem): number {
return right.version_number - left.version_number || right.id.localeCompare(left.id);
}
function rowVersionId(row: OperatorRow | null): string {
if (!row) return "";
return String(row.version?.id ?? row.summary?.current_version?.id ?? row.campaign.current_version_id ?? "");
}
function campaignVersionIdentity(row: OperatorRow): string {
const versionId = rowVersionId(row);
const versionNumber = row.version?.version_number ?? row.summary?.current_version?.version_number;
if (!versionId) return "—";
return `${versionNumber == null ? "#—" : `#${versionNumber}`} · ${shortSafeId(versionId)}`;
}
function jobVersionIdentity(job: Record<string, unknown>, row: OperatorRow | null): string {
const versionId = String(job.campaign_version_id ?? rowVersionId(row) ?? "");
const versionNumber = row?.version?.version_number ?? row?.summary?.current_version?.version_number;
if (!versionId) return "—";
return `${versionNumber == null ? "#—" : `#${versionNumber}`} · ${shortSafeId(versionId)}`;
}
function shortSafeId(value: string): string {
const normalized = value.trim();
if (!normalized) return "—";
return normalized.length > 8 ? `${normalized.slice(0, 8)}` : normalized;
}
function jobEntryIdentity(job: Record<string, unknown>): string {
const entryId = String(job.entry_id ?? "").trim();
if (entryId) return entryId;
const entryIndex = Number(job.entry_index ?? 0);
return i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: entryIndex || 1 });
}
function deliveryResultSummary(job: Record<string, unknown>): string {
const sendStatus = String(job.send_status ?? "");
if (sendStatus && sendStatus !== "not_queued") return deliveryStatusOptionLabel(sendStatus);
const imapStatus = String(job.imap_status ?? "");
if (imapStatus && imapStatus !== "not_requested") return deliveryStatusOptionLabel(imapStatus);
return "—";
}
function durableResultText(value: unknown): string | null {
const normalized = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
if (!normalized) return null;
return normalized.length > 240 ? `${normalized.slice(0, 239)}` : normalized;
}
function recipientReportPath(row: OperatorRow, versionId: string, job: Record<string, unknown>): string {
const entryId = String(job.entry_id ?? "").trim();
const recipient = String(job.recipient_email ?? "").trim();
const params = new URLSearchParams({ version: versionId, q: entryId || recipient });
return `/campaigns/${row.campaign.id}/report?${params.toString()}`;
}
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
if (value === "recipient" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
return value;
}
return "number";
}
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function isActiveJob(job: Record<string, unknown>): boolean {
return ["queued", "claimed", "sending"].includes(String(job.send_status ?? ""));
}
function deliveryStatusOptionLabel(value: string): string {
const labels: Record<string, string> = {
draft: "i18n:govoplan-campaign.draft.23d33e22",
queued: "i18n:govoplan-campaign.queued.6a599877",
sending: "i18n:govoplan-campaign.sending.ceafde86",
paused: "i18n:govoplan-campaign.paused.c7dfb6f1",
cancelled: "i18n:govoplan-campaign.cancelled.a1bf92ef",
not_queued: "i18n:govoplan-campaign.not_queued.b7f41e1e",
skipped: "i18n:govoplan-campaign.skipped.5a000ad7",
claimed: "i18n:govoplan-campaign.claimed.83c87884",
smtp_accepted: "i18n:govoplan-campaign.smtp_accepted.e3aa7603",
sent: "i18n:govoplan-campaign.sent.35f49dcf",
outcome_unknown: "i18n:govoplan-campaign.outcome_unknown.6e929fca",
failed_temporary: "i18n:govoplan-campaign.temporarily_failed.50d9b1a8",
failed_permanent: "i18n:govoplan-campaign.permanently_failed.32eda23a",
not_requested: "i18n:govoplan-campaign.not_requested.a0f3bce4",
pending: "i18n:govoplan-campaign.pending.96f608c1",
appending: "i18n:govoplan-campaign.appending.167de6b2",
appended: "i18n:govoplan-campaign.appended.979f5a82",
failed: "i18n:govoplan-campaign.failed.09fef5d8"
};
return labels[value] ?? value;
}
@@ -1,5 +1,6 @@
export type OperatorQueueAction =
| "details"
| "report"
| "pause"
| "resume"
| "retry"
@@ -9,6 +10,7 @@ export type OperatorQueueAction =
export type OperatorQueuePermissions = {
canRead: boolean;
canOpenReport: boolean;
canControl: boolean;
canRetry: boolean;
canQueue: boolean;
@@ -19,7 +21,7 @@ export type OperatorQueueFacts = {
retryable: number;
outcomeUnknown: number;
queueableUnattempted: number;
queued: number;
pausable: number;
paused: number;
cancellable: number;
};
@@ -34,6 +36,40 @@ export const OPERATOR_QUEUE_REASON = {
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
} as const;
export type CampaignVersionWorkItem<T> = {
campaignId: string;
versionId: string;
versionNumber: number;
isCurrent: boolean;
summary: T | null;
};
/** Preserve every audit version so work does not disappear when a new draft becomes current. */
export function campaignVersionWorkItems<T>(
campaignId: string,
currentVersionId: string | null | undefined,
versions: Array<{id: string;version_number: number;}>,
summaries: Record<string, T | null>
): CampaignVersionWorkItem<T>[] {
return versions.map((version) => ({
campaignId,
versionId: version.id,
versionNumber: version.version_number,
isCurrent: version.id === currentVersionId,
summary: summaries[version.id] ?? null
}));
}
export type CampaignLifecycleTotals = {pausable: number;paused: number;cancellable: number;};
export function campaignLifecycleTotals(items: CampaignLifecycleTotals[]): CampaignLifecycleTotals {
return items.reduce((totals, item) => ({
pausable: totals.pausable + item.pausable,
paused: totals.paused + item.paused,
cancellable: totals.cancellable + item.cancellable
}), { pausable: 0, paused: 0, cancellable: 0 });
}
/**
* Return the reason each fixed queue action is unavailable. A null result means
* the action is permitted and meaningful for the current queue projection.
@@ -44,9 +80,10 @@ export function operatorQueueActionBlocks(
): Record<OperatorQueueAction, string | null> {
return {
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permissionDenied,
report: permissions.canOpenReport ? null : OPERATOR_QUEUE_REASON.permissionDenied,
pause: !permissions.canControl
? OPERATOR_QUEUE_REASON.permissionDenied
: facts.queued > 0
: facts.pausable > 0
? null
: OPERATOR_QUEUE_REASON.noQueued,
resume: !permissions.canControl