feat(campaign): complete durable operator queue
This commit is contained in:
@@ -1,20 +1,30 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Ban, ExternalLink, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-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 type { ApiSettings, CampaignListItem } from "../../types";
|
||||||
import {
|
import {
|
||||||
cancelCampaign,
|
cancelCampaign,
|
||||||
|
getCampaignJobs,
|
||||||
|
getCampaignSummary,
|
||||||
getCampaignWorkspaceDelta,
|
getCampaignWorkspaceDelta,
|
||||||
listCampaignsDelta,
|
listCampaignsDelta,
|
||||||
pauseCampaign,
|
pauseCampaign,
|
||||||
resumeCampaign,
|
resumeCampaign,
|
||||||
retryCampaignJobs,
|
retryCampaignJobs,
|
||||||
sendUnattemptedCampaignJobs,
|
sendUnattemptedCampaignJobs,
|
||||||
type CampaignSummary
|
type CampaignJobsResponse,
|
||||||
|
type CampaignSummary,
|
||||||
|
type CampaignVersionListItem
|
||||||
} from "../../api/campaigns";
|
} from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } 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 { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
@@ -31,7 +41,11 @@ import {
|
|||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { asRecord, formatDateTime } from "../campaigns/utils/campaignView";
|
import { asRecord, formatDateTime } from "../campaigns/utils/campaignView";
|
||||||
import { deliveryModeLabel } from "../campaigns/utils/deliveryMode";
|
import { deliveryModeLabel } from "../campaigns/utils/deliveryMode";
|
||||||
|
import { emptyCampaignJobsResponse } from "../campaigns/utils/jobDeltas";
|
||||||
|
import type { CampaignJobSortColumn } from "../campaigns/utils/jobListQuery";
|
||||||
import {
|
import {
|
||||||
|
campaignLifecycleTotals,
|
||||||
|
campaignVersionWorkItems,
|
||||||
operatorQueueActionBlocks,
|
operatorQueueActionBlocks,
|
||||||
type OperatorQueueAction,
|
type OperatorQueueAction,
|
||||||
type OperatorQueuePermissions
|
type OperatorQueuePermissions
|
||||||
@@ -39,6 +53,8 @@ import {
|
|||||||
|
|
||||||
type OperatorRow = {
|
type OperatorRow = {
|
||||||
campaign: CampaignListItem;
|
campaign: CampaignListItem;
|
||||||
|
version: CampaignVersionListItem | null;
|
||||||
|
isCurrent: boolean;
|
||||||
summary: CampaignSummary | null;
|
summary: CampaignSummary | null;
|
||||||
mode: string | null;
|
mode: string | null;
|
||||||
modeSelectedAt: string | null;
|
modeSelectedAt: string | null;
|
||||||
@@ -48,22 +64,64 @@ type OperatorRow = {
|
|||||||
notAttempted: number;
|
notAttempted: number;
|
||||||
queuedOrActive: number;
|
queuedOrActive: number;
|
||||||
queued: number;
|
queued: number;
|
||||||
|
claimed: number;
|
||||||
|
sending: number;
|
||||||
|
completed: number;
|
||||||
|
pausable: number;
|
||||||
paused: number;
|
paused: number;
|
||||||
|
campaignPausable: number;
|
||||||
|
campaignPaused: number;
|
||||||
imapFailed: number;
|
imapFailed: number;
|
||||||
queueable: number;
|
queueable: number;
|
||||||
queueableUnattempted: number;
|
queueableUnattempted: number;
|
||||||
needsAttention: number;
|
needsAttention: number;
|
||||||
cancellable: number;
|
cancellable: number;
|
||||||
|
campaignCancellable: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type QueueMutationAction = "pause" | "resume" | "retry" | "queue-unsent" | "cancel";
|
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;}) {
|
export default function OperatorQueuePage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const selectedCampaignId = searchParams.get("campaign") ?? "";
|
||||||
|
const selectedVersionParam = searchParams.get("version") ?? "";
|
||||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
const [rows, setRows] = useState<OperatorRow[]>([]);
|
const [rows, setRows] = useState<OperatorRow[]>([]);
|
||||||
const campaignsRef = useRef<CampaignListItem[]>([]);
|
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 loadingRef = useRef(false);
|
||||||
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
|
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -71,9 +129,20 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [cancelTarget, setCancelTarget] = useState<OperatorRow | null>(null);
|
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>(() => ({
|
const permissions = useMemo<OperatorQueuePermissions>(() => ({
|
||||||
canRead: hasScope(auth, "campaigns:campaign:read")
|
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:report:read")
|
||||||
&& hasScope(auth, "campaigns:recipient:read"),
|
&& hasScope(auth, "campaigns:recipient:read"),
|
||||||
canControl: hasScope(auth, "campaigns:campaign:control"),
|
canControl: hasScope(auth, "campaigns:campaign:control"),
|
||||||
@@ -94,26 +163,95 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
}),
|
}),
|
||||||
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
[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(() => {
|
useEffect(() => {
|
||||||
campaignsRef.current = [];
|
campaignsRef.current = [];
|
||||||
summariesRef.current = {};
|
summariesRef.current = {};
|
||||||
|
versionsRef.current = {};
|
||||||
resetDeltaWatermark();
|
resetDeltaWatermark();
|
||||||
void load();
|
void load();
|
||||||
}, [settingsKey, resetDeltaWatermark]);
|
}, [settingsKey, resetDeltaWatermark]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
backgroundLoadRef.current = () => load(true);
|
backgroundLoadRef.current = () => load(true);
|
||||||
|
backgroundJobsLoadRef.current = () => loadSelectedJobs(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0);
|
|
||||||
useEffect(() => {
|
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(() => {
|
const handle = window.setInterval(() => {
|
||||||
void backgroundLoadRef.current();
|
void backgroundLoadRef.current();
|
||||||
|
if (selectedQueueActive) void backgroundJobsLoadRef.current();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
return () => window.clearInterval(handle);
|
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) {
|
async function load(background = false) {
|
||||||
if (loadingRef.current) return;
|
if (loadingRef.current) return;
|
||||||
@@ -122,8 +260,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const campaigns = await loadCampaignsDelta();
|
const campaigns = await loadCampaignsDelta();
|
||||||
const summaries = await loadCampaignSummariesDelta(campaigns);
|
setRows(await loadCampaignVersionRows(campaigns));
|
||||||
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} 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) {
|
async function runAction(row: OperatorRow, action: QueueMutationAction) {
|
||||||
const key = `${action}:${row.campaign.id}`;
|
const key = `${action}:${row.campaign.id}`;
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
@@ -139,9 +307,15 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
setMessage("");
|
setMessage("");
|
||||||
try {
|
try {
|
||||||
const response = action === "retry"
|
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"
|
: 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"
|
: action === "pause"
|
||||||
? await pauseCampaign(settings, row.campaign.id)
|
? await pauseCampaign(settings, row.campaign.id)
|
||||||
: action === "resume"
|
: action === "resume"
|
||||||
@@ -157,8 +331,8 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
}
|
}
|
||||||
));
|
));
|
||||||
if (action === "cancel") setCancelTarget(null);
|
if (action === "cancel") setCancelTarget(null);
|
||||||
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
|
resetDeltaWatermark(operatorCampaignWorkspaceKey(row.campaign.id));
|
||||||
await load();
|
await refreshAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -189,92 +363,190 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
return campaigns;
|
return campaigns;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
|
async function loadCampaignVersionRows(campaigns: CampaignListItem[]): Promise<OperatorRow[]> {
|
||||||
const summaries = { ...summariesRef.current };
|
const summaries = { ...summariesRef.current };
|
||||||
|
const versions = { ...versionsRef.current };
|
||||||
await Promise.all(campaigns.map(async (campaign) => {
|
await Promise.all(campaigns.map(async (campaign) => {
|
||||||
const key = operatorCampaignSummaryKey(campaign.id);
|
const key = operatorCampaignWorkspaceKey(campaign.id);
|
||||||
let nextWatermark = getDeltaWatermark(key);
|
let nextWatermark = getDeltaWatermark(key);
|
||||||
let summary = summaries[campaign.id] ?? null;
|
let campaignVersions = versions[campaign.id] ?? [];
|
||||||
let hasMore = false;
|
let hasMore = false;
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
|
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
|
||||||
includeCurrentVersion: false,
|
includeCurrentVersion: false,
|
||||||
includeVersions: false,
|
includeVersions: true,
|
||||||
includeSummary: true,
|
includeSummary: false,
|
||||||
since: nextWatermark
|
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;
|
nextWatermark = response.watermark ?? null;
|
||||||
hasMore = response.has_more;
|
hasMore = response.has_more;
|
||||||
} while (hasMore);
|
} while (hasMore);
|
||||||
setDeltaWatermark(key, nextWatermark);
|
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 {
|
} catch {
|
||||||
summaries[campaign.id] = summary;
|
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 {
|
||||||
|
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;
|
summariesRef.current = summaries;
|
||||||
return summaries;
|
return campaigns.flatMap((campaign) => toVersionRows(
|
||||||
|
campaign,
|
||||||
|
versions[campaign.id] ?? [],
|
||||||
|
summaries[campaign.id] ?? {}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function operatorCampaignListKey(): string {
|
function operatorCampaignListKey(): string {
|
||||||
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
|
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
function operatorCampaignSummaryKey(campaignId: string): string {
|
function operatorCampaignWorkspaceKey(campaignId: string): string {
|
||||||
return JSON.stringify({ scope: "operator-campaign-summary", campaignId, settingsKey });
|
return JSON.stringify({ scope: "operator-campaign-workspace", campaignId, settingsKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = rows.reduce((acc, row) => ({
|
const totals = rows.reduce((acc, row) => ({
|
||||||
failed: acc.failed + row.failed,
|
failed: acc.failed + row.failed,
|
||||||
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
|
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
|
||||||
notAttempted: acc.notAttempted + row.notAttempted,
|
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,
|
paused: acc.paused + row.paused,
|
||||||
imapFailed: acc.imapFailed + row.imapFailed
|
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>[]>(() => [
|
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: "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: "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: "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: "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: "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: "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: "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: "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",
|
id: "actions",
|
||||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 330,
|
width: 330,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => {
|
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) =>
|
const disabledReason = (action: OperatorQueueAction) =>
|
||||||
blocks[action] ?? (busy && action !== "details"
|
blocks[action] ?? (busy && action !== "details"
|
||||||
? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea"
|
? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea"
|
||||||
: undefined);
|
: 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={[
|
return <TableActionGroup actions={[
|
||||||
{
|
{
|
||||||
id: "details",
|
id: "details",
|
||||||
label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }),
|
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" />,
|
icon: <ExternalLink aria-hidden="true" />,
|
||||||
disabledReason: disabledReason("details"),
|
disabledReason: reportDisabledReason,
|
||||||
onClick: () => navigate(`/campaigns/${row.campaign.id}/report`)
|
onClick: () => {
|
||||||
|
const versionId = rowVersionId(row);
|
||||||
|
if (versionId) navigate(`/campaigns/${row.campaign.id}/report?version=${encodeURIComponent(versionId)}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pause",
|
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" />,
|
icon: <Pause aria-hidden="true" />,
|
||||||
disabledReason: disabledReason("pause"),
|
disabledReason: disabledReason("pause"),
|
||||||
onClick: () => void runAction(row, "pause")
|
onClick: () => void runAction(row, "pause")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "resume",
|
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" />,
|
icon: <Play aria-hidden="true" />,
|
||||||
disabledReason: disabledReason("resume"),
|
disabledReason: disabledReason("resume"),
|
||||||
onClick: () => void runAction(row, "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",
|
label: "i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7",
|
||||||
icon: <ShieldCheck aria-hidden="true" />,
|
icon: <ShieldCheck aria-hidden="true" />,
|
||||||
disabledReason: disabledReason("reconcile"),
|
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",
|
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" />,
|
icon: <Ban aria-hidden="true" />,
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
disabledReason: disabledReason("cancel"),
|
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 (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<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>
|
<p className="muted small-note">i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{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>}
|
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||||
|
|
||||||
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
|
<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>
|
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
|
||||||
<QueuePressureGrid items={[
|
<div className="metric-grid queue-pressure-grid">
|
||||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
|
<MetricCard label="i18n:govoplan-campaign.queued.6a599877" value={totals.queued} tone="neutral" />
|
||||||
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
|
<MetricCard label="i18n:govoplan-campaign.claimed.83c87884" value={totals.claimed} tone="info" />
|
||||||
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
|
<MetricCard label="i18n:govoplan-campaign.sending.ceafde86" value={totals.sending} tone="info" />
|
||||||
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
|
<MetricCard label="i18n:govoplan-campaign.completed.1798b3ba" value={totals.completed} tone="good" />
|
||||||
{ label: "i18n:govoplan-campaign.paused.c7dfb6f1", value: totals.paused, tone: "neutral" },
|
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={totals.failed} tone="danger" />
|
||||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
|
<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>
|
</section>
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
|
<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"
|
id="operator-queue-campaigns"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowKey={(row) => row.campaign.id}
|
getRowKey={(row) => `${row.campaign.id}:${rowVersionId(row) || "none"}`}
|
||||||
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
|
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
|
||||||
className="data-table compact-table" />
|
className="data-table compact-table" />
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</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
|
<ConfirmDialog
|
||||||
open={Boolean(cancelTarget)}
|
open={Boolean(cancelTarget)}
|
||||||
title="i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e"
|
title="i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e"
|
||||||
message="i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb"
|
message="i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34"
|
||||||
confirmLabel="i18n:govoplan-campaign.cancel_queued_work.a54fb955"
|
confirmLabel="i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b"
|
||||||
tone="danger"
|
tone="danger"
|
||||||
busy={Boolean(busy)}
|
busy={Boolean(busy)}
|
||||||
onConfirm={() => {
|
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 cards = asRecord(summary?.cards);
|
||||||
const statusCounts = asRecord(summary?.status_counts);
|
const statusCounts = asRecord(summary?.status_counts);
|
||||||
const queueCounts = asRecord(statusCounts.queue);
|
const queueCounts = asRecord(statusCounts.queue);
|
||||||
const queued = numberValue(queueCounts.queued);
|
const sendCounts = asRecord(statusCounts.send);
|
||||||
const paused = numberValue(queueCounts.paused);
|
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 failed = numberValue(cards.failed);
|
||||||
const notAttempted = numberValue(cards.not_attempted);
|
const notAttempted = numberValue(cards.not_attempted);
|
||||||
return {
|
return {
|
||||||
campaign,
|
campaign,
|
||||||
|
version,
|
||||||
|
isCurrent,
|
||||||
summary,
|
summary,
|
||||||
mode: summary?.current_version?.delivery_mode ?? null,
|
mode: summary?.current_version?.delivery_mode ?? version?.delivery_mode ?? null,
|
||||||
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? null,
|
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? version?.delivery_mode_selected_at ?? null,
|
||||||
failed,
|
failed,
|
||||||
retryable: numberValue(cards.retryable),
|
retryable: numberValue(cards.retryable),
|
||||||
outcomeUnknown: numberValue(cards.outcome_unknown),
|
outcomeUnknown: numberValue(cards.outcome_unknown),
|
||||||
notAttempted,
|
notAttempted,
|
||||||
queuedOrActive: numberValue(cards.queued_or_active),
|
queuedOrActive: queued + claimed + sending,
|
||||||
queued,
|
queued,
|
||||||
paused,
|
claimed,
|
||||||
|
sending,
|
||||||
|
completed,
|
||||||
|
pausable: numberValue(queueCounts.queued),
|
||||||
|
paused: numberValue(queueCounts.paused),
|
||||||
|
campaignPausable: lifecycle.pausable,
|
||||||
|
campaignPaused: lifecycle.paused,
|
||||||
imapFailed: numberValue(cards.imap_failed),
|
imapFailed: numberValue(cards.imap_failed),
|
||||||
queueable: numberValue(cards.queueable),
|
queueable: numberValue(cards.queueable),
|
||||||
queueableUnattempted: numberValue(cards.queueable_unattempted),
|
queueableUnattempted: numberValue(cards.queueable_unattempted),
|
||||||
needsAttention: numberValue(cards.needs_attention),
|
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 {
|
function actionLabel(action: QueueMutationAction): string {
|
||||||
if (action === "pause") return "i18n:govoplan-campaign.pause_queue.b66e7696";
|
if (action === "pause") return "i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf";
|
||||||
if (action === "resume") return "i18n:govoplan-campaign.resume_queue.1f22f964";
|
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 === "retry") return "i18n:govoplan-campaign.retry.9f5cd8a2";
|
||||||
if (action === "queue-unsent") return "i18n:govoplan-campaign.queue_unsent.b0e98610";
|
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 {
|
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";}>;}) {
|
function sortVersionsNewestFirst(left: CampaignVersionListItem, right: CampaignVersionListItem): number {
|
||||||
return (
|
return right.version_number - left.version_number || right.id.localeCompare(left.id);
|
||||||
<div className="metric-grid queue-pressure-grid">
|
}
|
||||||
{items.map((item) =>
|
|
||||||
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
|
function rowVersionId(row: OperatorRow | null): string {
|
||||||
)}
|
if (!row) return "";
|
||||||
</div>);
|
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 =
|
export type OperatorQueueAction =
|
||||||
| "details"
|
| "details"
|
||||||
|
| "report"
|
||||||
| "pause"
|
| "pause"
|
||||||
| "resume"
|
| "resume"
|
||||||
| "retry"
|
| "retry"
|
||||||
@@ -9,6 +10,7 @@ export type OperatorQueueAction =
|
|||||||
|
|
||||||
export type OperatorQueuePermissions = {
|
export type OperatorQueuePermissions = {
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
|
canOpenReport: boolean;
|
||||||
canControl: boolean;
|
canControl: boolean;
|
||||||
canRetry: boolean;
|
canRetry: boolean;
|
||||||
canQueue: boolean;
|
canQueue: boolean;
|
||||||
@@ -19,7 +21,7 @@ export type OperatorQueueFacts = {
|
|||||||
retryable: number;
|
retryable: number;
|
||||||
outcomeUnknown: number;
|
outcomeUnknown: number;
|
||||||
queueableUnattempted: number;
|
queueableUnattempted: number;
|
||||||
queued: number;
|
pausable: number;
|
||||||
paused: number;
|
paused: number;
|
||||||
cancellable: number;
|
cancellable: number;
|
||||||
};
|
};
|
||||||
@@ -34,6 +36,40 @@ export const OPERATOR_QUEUE_REASON = {
|
|||||||
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
|
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
|
||||||
} as const;
|
} 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
|
* Return the reason each fixed queue action is unavailable. A null result means
|
||||||
* the action is permitted and meaningful for the current queue projection.
|
* the action is permitted and meaningful for the current queue projection.
|
||||||
@@ -44,9 +80,10 @@ export function operatorQueueActionBlocks(
|
|||||||
): Record<OperatorQueueAction, string | null> {
|
): Record<OperatorQueueAction, string | null> {
|
||||||
return {
|
return {
|
||||||
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permissionDenied,
|
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permissionDenied,
|
||||||
|
report: permissions.canOpenReport ? null : OPERATOR_QUEUE_REASON.permissionDenied,
|
||||||
pause: !permissions.canControl
|
pause: !permissions.canControl
|
||||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||||
: facts.queued > 0
|
: facts.pausable > 0
|
||||||
? null
|
? null
|
||||||
: OPERATOR_QUEUE_REASON.noQueued,
|
: OPERATOR_QUEUE_REASON.noQueued,
|
||||||
resume: !permissions.canControl
|
resume: !permissions.canControl
|
||||||
|
|||||||
@@ -4,22 +4,40 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"en": {
|
"en": {
|
||||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||||
|
"i18n:govoplan-campaign.appended.979f5a82": "Appended",
|
||||||
|
"i18n:govoplan-campaign.appending.167de6b2": "Appending",
|
||||||
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Another queue action is still running.",
|
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Another queue action is still running.",
|
||||||
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Cancel eligible queued work?",
|
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Cancel eligible queued work?",
|
||||||
|
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Cancel all eligible queued work for this Campaign",
|
||||||
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Cancel queued work",
|
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Cancel queued work",
|
||||||
|
"i18n:govoplan-campaign.claimed.83c87884": "Claimed",
|
||||||
|
"i18n:govoplan-campaign.current.4fc0e2bc": "Current",
|
||||||
|
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Delivery jobs for {value0}",
|
||||||
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Eligible jobs that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
|
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Eligible jobs that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
|
||||||
|
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Eligible jobs in every version of this Campaign that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
|
||||||
|
"i18n:govoplan-campaign.historical.bb2fe205": "Historical",
|
||||||
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "No cancellable queued work remains.",
|
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "No cancellable queued work remains.",
|
||||||
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "No failed jobs are eligible for retry.",
|
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "No failed jobs are eligible for retry.",
|
||||||
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "No paused jobs can be resumed.",
|
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "No paused jobs can be resumed.",
|
||||||
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "No queued jobs can be paused.",
|
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "No queued jobs can be paused.",
|
||||||
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "No uncertain outcomes require reconciliation.",
|
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "No uncertain outcomes require reconciliation.",
|
||||||
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "No unsent jobs are available to queue.",
|
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "No unsent jobs are available to queue.",
|
||||||
|
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Not requested",
|
||||||
|
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Open recipient report",
|
||||||
"i18n:govoplan-campaign.pause_queue.b66e7696": "Pause queue",
|
"i18n:govoplan-campaign.pause_queue.b66e7696": "Pause queue",
|
||||||
|
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Pause all queued work for this Campaign",
|
||||||
"i18n:govoplan-campaign.paused.c7dfb6f1": "Paused",
|
"i18n:govoplan-campaign.paused.c7dfb6f1": "Paused",
|
||||||
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Queue action for {value0} completed: {value1} ({value2}).",
|
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Queue action for {value0} completed: {value1} ({value2}).",
|
||||||
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Queue refreshes automatically while delivery is active.",
|
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Queue refreshes automatically while delivery is active.",
|
||||||
|
"i18n:govoplan-campaign.queued_at.1ac179b3": "Queued at",
|
||||||
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
|
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
|
||||||
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
|
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
|
||||||
|
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Resume all paused work for this Campaign",
|
||||||
|
"i18n:govoplan-campaign.sending.ceafde86": "Sending",
|
||||||
|
"i18n:govoplan-campaign.sent_at.a6f45556": "Sent at",
|
||||||
|
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Temporarily failed",
|
||||||
|
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Permanently failed",
|
||||||
|
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "This Campaign queue is already selected.",
|
||||||
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
|
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
|
||||||
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Campaign reports available to you",
|
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Campaign reports available to you",
|
||||||
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "Cancelled {value0} unsent message(s); {value1} protected outcome(s) and {value2} excluded/skipped message(s) were retained.",
|
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "Cancelled {value0} unsent message(s); {value1} protected outcome(s) and {value2} excluded/skipped message(s) were retained.",
|
||||||
@@ -1269,22 +1287,40 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"de": {
|
"de": {
|
||||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||||
|
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
|
||||||
|
"i18n:govoplan-campaign.appending.167de6b2": "Wird abgelegt",
|
||||||
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Eine andere Warteschlangenaktion wird noch ausgeführt.",
|
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Eine andere Warteschlangenaktion wird noch ausgeführt.",
|
||||||
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Geeignete Warteschlangenaufträge abbrechen?",
|
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Geeignete Warteschlangenaufträge abbrechen?",
|
||||||
|
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Alle geeigneten Warteschlangenaufträge dieser Kampagne abbrechen",
|
||||||
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
|
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
|
||||||
|
"i18n:govoplan-campaign.claimed.83c87884": "Beansprucht",
|
||||||
|
"i18n:govoplan-campaign.current.4fc0e2bc": "Aktuell",
|
||||||
|
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Versandaufträge für {value0}",
|
||||||
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Geeignete Aufträge, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
|
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Geeignete Aufträge, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
|
||||||
|
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Geeignete Aufträge in allen Versionen dieser Kampagne, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
|
||||||
|
"i18n:govoplan-campaign.historical.bb2fe205": "Historisch",
|
||||||
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "Es sind keine abbrechbaren Warteschlangenaufträge mehr vorhanden.",
|
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "Es sind keine abbrechbaren Warteschlangenaufträge mehr vorhanden.",
|
||||||
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "Keine fehlgeschlagenen Aufträge können erneut versucht werden.",
|
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "Keine fehlgeschlagenen Aufträge können erneut versucht werden.",
|
||||||
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "Es sind keine pausierten Aufträge zum Fortsetzen vorhanden.",
|
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "Es sind keine pausierten Aufträge zum Fortsetzen vorhanden.",
|
||||||
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "Es sind keine wartenden Aufträge zum Pausieren vorhanden.",
|
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "Es sind keine wartenden Aufträge zum Pausieren vorhanden.",
|
||||||
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "Es müssen keine ungeklärten Ergebnisse abgestimmt werden.",
|
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "Es müssen keine ungeklärten Ergebnisse abgestimmt werden.",
|
||||||
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "Es sind keine ungesendeten Aufträge für die Warteschlange verfügbar.",
|
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "Es sind keine ungesendeten Aufträge für die Warteschlange verfügbar.",
|
||||||
|
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Nicht angefordert",
|
||||||
|
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Empfängerbezogenen Bericht öffnen",
|
||||||
"i18n:govoplan-campaign.pause_queue.b66e7696": "Warteschlange pausieren",
|
"i18n:govoplan-campaign.pause_queue.b66e7696": "Warteschlange pausieren",
|
||||||
|
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Alle eingereihten Aufträge dieser Kampagne pausieren",
|
||||||
"i18n:govoplan-campaign.paused.c7dfb6f1": "Pausiert",
|
"i18n:govoplan-campaign.paused.c7dfb6f1": "Pausiert",
|
||||||
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Warteschlangenaktion für {value0} abgeschlossen: {value1} ({value2}).",
|
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Warteschlangenaktion für {value0} abgeschlossen: {value1} ({value2}).",
|
||||||
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Die Warteschlange wird während eines laufenden Versands automatisch aktualisiert.",
|
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Die Warteschlange wird während eines laufenden Versands automatisch aktualisiert.",
|
||||||
|
"i18n:govoplan-campaign.queued_at.1ac179b3": "Eingereiht am",
|
||||||
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
|
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
|
||||||
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
|
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
|
||||||
|
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Alle pausierten Aufträge dieser Kampagne fortsetzen",
|
||||||
|
"i18n:govoplan-campaign.sending.ceafde86": "Im Versand",
|
||||||
|
"i18n:govoplan-campaign.sent_at.a6f45556": "Gesendet am",
|
||||||
|
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Vorübergehend fehlgeschlagen",
|
||||||
|
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Endgültig fehlgeschlagen",
|
||||||
|
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "Diese Kampagnenwarteschlange ist bereits ausgewählt.",
|
||||||
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
|
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
|
||||||
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Für Sie verfügbare Kampagnenberichte",
|
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Für Sie verfügbare Kampagnenberichte",
|
||||||
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "{value0} ungesendete Nachricht(en) wurden abgebrochen; {value1} geschützte Ergebnisse und {value2} ausgeschlossene/übersprungene Nachricht(en) wurden beibehalten.",
|
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "{value0} ungesendete Nachricht(en) wurden abgebrochen; {value1} geschützte Ergebnisse und {value2} ausgeschlossene/übersprungene Nachricht(en) wurden beibehalten.",
|
||||||
@@ -1583,7 +1619,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.can_view.1b3e4006": "Can view",
|
"i18n:govoplan-campaign.can_view.1b3e4006": "Can view",
|
||||||
"i18n:govoplan-campaign.cancel_filename_changes.b558598e": "Cancel filename changes",
|
"i18n:govoplan-campaign.cancel_filename_changes.b558598e": "Cancel filename changes",
|
||||||
"i18n:govoplan-campaign.cancel.77dfd213": "Abbrechen",
|
"i18n:govoplan-campaign.cancel.77dfd213": "Abbrechen",
|
||||||
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Cancelled",
|
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Abgebrochen",
|
||||||
"i18n:govoplan-campaign.captured_mailbox_preview_is_disabled_for_this_ru.6559458d": "Captured mailbox preview is disabled for this run. Recipient outcomes remain available.",
|
"i18n:govoplan-campaign.captured_mailbox_preview_is_disabled_for_this_ru.6559458d": "Captured mailbox preview is disabled for this run. Recipient outcomes remain available.",
|
||||||
"i18n:govoplan-campaign.captured_mailbox_preview_requires_the_mail_devel.9b3d9f0d": "Captured mailbox preview requires the Mail development mailbox API.",
|
"i18n:govoplan-campaign.captured_mailbox_preview_requires_the_mail_devel.9b3d9f0d": "Captured mailbox preview requires the Mail development mailbox API.",
|
||||||
"i18n:govoplan-campaign.captured_messages.a833d293": "Captured messages",
|
"i18n:govoplan-campaign.captured_messages.a833d293": "Captured messages",
|
||||||
@@ -1615,7 +1651,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.compatibility_notes.7d648a6d": "Compatibility notes",
|
"i18n:govoplan-campaign.compatibility_notes.7d648a6d": "Compatibility notes",
|
||||||
"i18n:govoplan-campaign.complete_a_successful_mock_delivery_first.bc2a587d": "Complete a successful mock delivery first.",
|
"i18n:govoplan-campaign.complete_a_successful_mock_delivery_first.bc2a587d": "Complete a successful mock delivery first.",
|
||||||
"i18n:govoplan-campaign.complete_review.4c2ed8c8": "Complete review",
|
"i18n:govoplan-campaign.complete_review.4c2ed8c8": "Complete review",
|
||||||
"i18n:govoplan-campaign.completed.1798b3ba": "Completed",
|
"i18n:govoplan-campaign.completed.1798b3ba": "Abgeschlossen",
|
||||||
"i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b": "Compose the subject and body. Merge fields can later be inserted from the field picker.",
|
"i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b": "Compose the subject and body. Merge fields can later be inserted from the field picker.",
|
||||||
"i18n:govoplan-campaign.concurrency.2ec390bf": "Concurrency",
|
"i18n:govoplan-campaign.concurrency.2ec390bf": "Concurrency",
|
||||||
"i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2": "Configure campaign-wide attachment behavior and global matching rules.",
|
"i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2": "Configure campaign-wide attachment behavior and global matching rules.",
|
||||||
@@ -2018,7 +2054,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.not_claimed.aa712c1b": "Not claimed",
|
"i18n:govoplan-campaign.not_claimed.aa712c1b": "Not claimed",
|
||||||
"i18n:govoplan-campaign.not_configured.67f2141f": "not configured",
|
"i18n:govoplan-campaign.not_configured.67f2141f": "not configured",
|
||||||
"i18n:govoplan-campaign.not_loaded_yet.9968c191": "Not loaded yet",
|
"i18n:govoplan-campaign.not_loaded_yet.9968c191": "Not loaded yet",
|
||||||
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Not queued",
|
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Nicht eingereiht",
|
||||||
"i18n:govoplan-campaign.not_reviewed.0a0e3cff": "Not reviewed",
|
"i18n:govoplan-campaign.not_reviewed.0a0e3cff": "Not reviewed",
|
||||||
"i18n:govoplan-campaign.not_run.9e019cd5": "Not run",
|
"i18n:govoplan-campaign.not_run.9e019cd5": "Not run",
|
||||||
"i18n:govoplan-campaign.not_sent.587c501e": "Not sent",
|
"i18n:govoplan-campaign.not_sent.587c501e": "Not sent",
|
||||||
@@ -2050,7 +2086,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.optional.0c6c4102": "Optional",
|
"i18n:govoplan-campaign.optional.0c6c4102": "Optional",
|
||||||
"i18n:govoplan-campaign.or_click_to_select_file.0e72f25d": "or click to select file",
|
"i18n:govoplan-campaign.or_click_to_select_file.0e72f25d": "or click to select file",
|
||||||
"i18n:govoplan-campaign.outcome_unknown.4383023a": ", outcome unknown",
|
"i18n:govoplan-campaign.outcome_unknown.4383023a": ", outcome unknown",
|
||||||
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Outcome unknown",
|
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Ergebnis ungeklärt",
|
||||||
"i18n:govoplan-campaign.overview.0efc2e6b": "Übersicht",
|
"i18n:govoplan-campaign.overview.0efc2e6b": "Übersicht",
|
||||||
"i18n:govoplan-campaign.owner_type.6b86eacc": "Owner type",
|
"i18n:govoplan-campaign.owner_type.6b86eacc": "Owner type",
|
||||||
"i18n:govoplan-campaign.owner.719379ae": "Owner:",
|
"i18n:govoplan-campaign.owner.719379ae": "Owner:",
|
||||||
@@ -2113,7 +2149,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.queued_jobs_are_waiting_for_a_background_deliver.cc703afb": "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs.",
|
"i18n:govoplan-campaign.queued_jobs_are_waiting_for_a_background_deliver.cc703afb": "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs.",
|
||||||
"i18n:govoplan-campaign.queued_jobs_are_waiting_if_the_counts_do_not_cha.7d2164d6": "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running.",
|
"i18n:govoplan-campaign.queued_jobs_are_waiting_if_the_counts_do_not_cha.7d2164d6": "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running.",
|
||||||
"i18n:govoplan-campaign.queued_jobs_are_waiting_in_the_database_but_back.5b1fadfd": "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true.",
|
"i18n:govoplan-campaign.queued_jobs_are_waiting_in_the_database_but_back.5b1fadfd": "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true.",
|
||||||
"i18n:govoplan-campaign.queued.6a599877": "Queued",
|
"i18n:govoplan-campaign.queued.6a599877": "Eingereiht",
|
||||||
"i18n:govoplan-campaign.queueing_pending_imap_append_jobs.31dd388e": "Queueing pending IMAP append jobs...",
|
"i18n:govoplan-campaign.queueing_pending_imap_append_jobs.31dd388e": "Queueing pending IMAP append jobs...",
|
||||||
"i18n:govoplan-campaign.quoted_contents.f7fd335a": "Quoted contents",
|
"i18n:govoplan-campaign.quoted_contents.f7fd335a": "Quoted contents",
|
||||||
"i18n:govoplan-campaign.rate_limit.d08e55f5": "Rate limit",
|
"i18n:govoplan-campaign.rate_limit.d08e55f5": "Rate limit",
|
||||||
@@ -2295,10 +2331,10 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
|
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
|
||||||
"i18n:govoplan-campaign.showing.163d8174": "Showing",
|
"i18n:govoplan-campaign.showing.163d8174": "Showing",
|
||||||
"i18n:govoplan-campaign.size.b7152342": "Größe",
|
"i18n:govoplan-campaign.size.b7152342": "Größe",
|
||||||
"i18n:govoplan-campaign.skipped.5a000ad7": "Skipped",
|
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
|
||||||
"i18n:govoplan-campaign.skipped.6b98496c": ", skipped",
|
"i18n:govoplan-campaign.skipped.6b98496c": ", skipped",
|
||||||
"i18n:govoplan-campaign.smtp_accepted.a5d0dccc": ", SMTP accepted",
|
"i18n:govoplan-campaign.smtp_accepted.a5d0dccc": ", SMTP accepted",
|
||||||
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "SMTP accepted",
|
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "Von SMTP angenommen",
|
||||||
"i18n:govoplan-campaign.smtp_attempts.eb0a9ca6": "SMTP attempts",
|
"i18n:govoplan-campaign.smtp_attempts.eb0a9ca6": "SMTP attempts",
|
||||||
"i18n:govoplan-campaign.smtp_credentials.10f75c8a": "). SMTP credentials:",
|
"i18n:govoplan-campaign.smtp_credentials.10f75c8a": "). SMTP credentials:",
|
||||||
"i18n:govoplan-campaign.smtp_failed.0ce5516d": "SMTP failed",
|
"i18n:govoplan-campaign.smtp_failed.0ce5516d": "SMTP failed",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import test from "node:test";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
OPERATOR_QUEUE_REASON,
|
OPERATOR_QUEUE_REASON,
|
||||||
|
campaignLifecycleTotals,
|
||||||
|
campaignVersionWorkItems,
|
||||||
operatorQueueActionBlocks,
|
operatorQueueActionBlocks,
|
||||||
type OperatorQueueFacts,
|
type OperatorQueueFacts,
|
||||||
type OperatorQueuePermissions
|
type OperatorQueuePermissions
|
||||||
@@ -12,13 +14,14 @@ const everything: OperatorQueueFacts = {
|
|||||||
retryable: 2,
|
retryable: 2,
|
||||||
outcomeUnknown: 1,
|
outcomeUnknown: 1,
|
||||||
queueableUnattempted: 3,
|
queueableUnattempted: 3,
|
||||||
queued: 4,
|
pausable: 4,
|
||||||
paused: 5,
|
paused: 5,
|
||||||
cancellable: 14
|
cancellable: 14
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPermissions: OperatorQueuePermissions = {
|
const allPermissions: OperatorQueuePermissions = {
|
||||||
canRead: true,
|
canRead: true,
|
||||||
|
canOpenReport: true,
|
||||||
canControl: true,
|
canControl: true,
|
||||||
canRetry: true,
|
canRetry: true,
|
||||||
canQueue: true,
|
canQueue: true,
|
||||||
@@ -28,6 +31,7 @@ const allPermissions: OperatorQueuePermissions = {
|
|||||||
test("enables every fixed queue action when state and permission allow it", () => {
|
test("enables every fixed queue action when state and permission allow it", () => {
|
||||||
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
|
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
|
||||||
details: null,
|
details: null,
|
||||||
|
report: null,
|
||||||
pause: null,
|
pause: null,
|
||||||
resume: null,
|
resume: null,
|
||||||
retry: null,
|
retry: null,
|
||||||
@@ -40,11 +44,12 @@ test("enables every fixed queue action when state and permission allow it", () =
|
|||||||
test("retains every unavailable action with a state-specific explanation", () => {
|
test("retains every unavailable action with a state-specific explanation", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
operatorQueueActionBlocks(
|
operatorQueueActionBlocks(
|
||||||
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, queued: 0, paused: 0, cancellable: 0 },
|
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, pausable: 0, paused: 0, cancellable: 0 },
|
||||||
allPermissions
|
allPermissions
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
details: null,
|
details: null,
|
||||||
|
report: null,
|
||||||
pause: OPERATOR_QUEUE_REASON.noQueued,
|
pause: OPERATOR_QUEUE_REASON.noQueued,
|
||||||
resume: OPERATOR_QUEUE_REASON.noPaused,
|
resume: OPERATOR_QUEUE_REASON.noPaused,
|
||||||
retry: OPERATOR_QUEUE_REASON.noFailed,
|
retry: OPERATOR_QUEUE_REASON.noFailed,
|
||||||
@@ -58,6 +63,7 @@ test("retains every unavailable action with a state-specific explanation", () =>
|
|||||||
test("permission explanations take precedence over queue state", () => {
|
test("permission explanations take precedence over queue state", () => {
|
||||||
const noPermissions: OperatorQueuePermissions = {
|
const noPermissions: OperatorQueuePermissions = {
|
||||||
canRead: false,
|
canRead: false,
|
||||||
|
canOpenReport: false,
|
||||||
canControl: false,
|
canControl: false,
|
||||||
canRetry: false,
|
canRetry: false,
|
||||||
canQueue: false,
|
canQueue: false,
|
||||||
@@ -66,6 +72,43 @@ test("permission explanations take precedence over queue state", () => {
|
|||||||
const blocks = operatorQueueActionBlocks(everything, noPermissions);
|
const blocks = operatorQueueActionBlocks(everything, noPermissions);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.values(blocks),
|
Object.values(blocks),
|
||||||
Array.from({ length: 7 }, () => OPERATOR_QUEUE_REASON.permissionDenied)
|
Array.from({ length: 8 }, () => OPERATOR_QUEUE_REASON.permissionDenied)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allows in-page job selection without report permission while keeping report access disabled", () => {
|
||||||
|
const blocks = operatorQueueActionBlocks(everything, {
|
||||||
|
...allPermissions,
|
||||||
|
canOpenReport: false
|
||||||
|
});
|
||||||
|
assert.equal(blocks.details, null);
|
||||||
|
assert.equal(blocks.report, OPERATOR_QUEUE_REASON.permissionDenied);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps historical active work visible after a new draft becomes current", () => {
|
||||||
|
const items = campaignVersionWorkItems(
|
||||||
|
"campaign-1",
|
||||||
|
"version-2",
|
||||||
|
[
|
||||||
|
{ id: "version-2", version_number: 2 },
|
||||||
|
{ id: "version-1", version_number: 1 }
|
||||||
|
],
|
||||||
|
{
|
||||||
|
"version-2": { state: "draft", queued: 0, failed: 0 },
|
||||||
|
"version-1": { state: "active", queued: 3, failed: 1 }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(items.map((item) => [item.versionId, item.isCurrent]), [
|
||||||
|
["version-2", true],
|
||||||
|
["version-1", false]
|
||||||
|
]);
|
||||||
|
assert.deepEqual(items[1].summary, { state: "active", queued: 3, failed: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("campaign-wide lifecycle totals are not multiplied across version rows", () => {
|
||||||
|
assert.deepEqual(campaignLifecycleTotals([
|
||||||
|
{ pausable: 3, paused: 1, cancellable: 4 },
|
||||||
|
{ pausable: 0, paused: 2, cancellable: 2 }
|
||||||
|
]), { pausable: 3, paused: 3, cancellable: 6 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,14 +5,72 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
|
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
|
||||||
|
const translations = readFileSync(resolve(here, "../src/i18n/generatedTranslations.ts"), "utf8");
|
||||||
|
const backendRouter = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/router.py"), "utf8");
|
||||||
|
const responseSecurity = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/response_security.py"), "utf8");
|
||||||
|
|
||||||
assert.match(source, /<DataGrid/);
|
assert.match(source, /<DataGrid/);
|
||||||
assert.match(source, /<TableActionGroup actions=/);
|
assert.match(source, /<TableActionGroup actions=/);
|
||||||
for (const action of ["details", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel"]) {
|
for (const action of ["details", "report", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel", "open-recipient-report"]) {
|
||||||
assert.match(source, new RegExp(`id: "${action}"`));
|
assert.match(source, new RegExp(`id: "${action}"`));
|
||||||
}
|
}
|
||||||
assert.match(source, /useDeltaWatermarks/);
|
assert.match(source, /useDeltaWatermarks/);
|
||||||
|
assert.match(source, /useSearchParams/);
|
||||||
|
assert.match(source, /searchParams\.get\("campaign"\)/);
|
||||||
|
assert.match(source, /searchParams\.get\("version"\)/);
|
||||||
|
assert.match(source, /next\.set\("campaign", campaignId\)[\s\S]*next\.set\("version", versionId\)/);
|
||||||
|
assert.match(source, /setSearchParams\(next, \{ replace: true \}\)/);
|
||||||
assert.match(source, /window\.setInterval[\s\S]*10_000/);
|
assert.match(source, /window\.setInterval[\s\S]*10_000/);
|
||||||
|
assert.match(source, /window\.setInterval[\s\S]*60_000/);
|
||||||
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
|
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
|
||||||
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision/);
|
assert.match(source, /includeVersions: true/);
|
||||||
|
assert.match(source, /getCampaignSummary\(settings, campaign\.id, version\.id\)/);
|
||||||
|
assert.match(source, /campaignVersionWorkItems/);
|
||||||
|
assert.match(source, /getCampaignJobs\(settings, selectedRow\.campaign\.id,[\s\S]*versionId: selectedVersionId/);
|
||||||
|
assert.doesNotMatch(source, /getCampaignJobDetail|getCampaignJobsDelta|diagnostics/);
|
||||||
|
assert.match(source, /mode: "server"/);
|
||||||
|
assert.match(source, /totalRows: jobs\.total/);
|
||||||
|
assert.match(source, /query=\{jobGridQuery\}/);
|
||||||
|
assert.match(source, /onQueryChange=\{handleJobGridQuery\}/);
|
||||||
|
assert.match(source, /pageSizeOptions: \[25, 50, 100, 200\]/);
|
||||||
|
assert.match(source, /jobsRequestRef\.current/);
|
||||||
|
assert.match(source, /requestId !== jobsRequestRef\.current/);
|
||||||
|
assert.match(source, /selectedQueueActive[\s\S]*backgroundJobsLoadRef/);
|
||||||
|
assert.match(source, /getRowKey=\{\(row\) => `\$\{row\.campaign\.id\}:\$\{rowVersionId\(row\)/);
|
||||||
|
assert.match(source, /row\.campaign\.id === selectedCampaignId && rowVersionId\(row\) === selectedVersionParam/);
|
||||||
|
assert.match(source, /retryCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
|
||||||
|
assert.match(source, /sendUnattemptedCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
|
||||||
|
assert.match(source, /report\?version=\$\{encodeURIComponent\(rowVersionId\(row\)\)\}&send_status=outcome_unknown/);
|
||||||
|
for (const count of ["queued", "claimed", "sending", "completed"]) {
|
||||||
|
assert.match(source, new RegExp(`row\\.${count}`));
|
||||||
|
assert.match(source, new RegExp(`totals\\.${count}`));
|
||||||
|
}
|
||||||
|
for (const field of ["campaign_version_id", "entry_id", "entry_index", "queue_status", "send_status", "imap_status", "attempt_count", "queued_at", "sent_at", "updated_at", "last_error"]) {
|
||||||
|
assert.match(source, new RegExp(field));
|
||||||
|
}
|
||||||
|
assert.match(source, /deliveryResultSummary[\s\S]*send_status[\s\S]*imap_status/);
|
||||||
|
assert.match(source, /durableResultText[\s\S]*slice\(0, 239\)/);
|
||||||
|
assert.match(source, /permissions\.canOpenReport[\s\S]*you_do_not_have_permission/);
|
||||||
|
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision|eml_sha256|message_id_header|smtp_response|provider_response|resolved_attachments/);
|
||||||
|
assert.match(backendRouter, /"last_error": public_delivery_result_message\([\s\S]*last_error=job\.last_error/);
|
||||||
|
assert.match(responseSecurity, /Map persisted provider text to a stable business-safe explanation/);
|
||||||
|
assert.match(responseSecurity, /SMTP delivery failed; an operator can inspect restricted diagnostics\./);
|
||||||
assert.doesNotMatch(source, /window\.alert\s*\(/);
|
assert.doesNotMatch(source, /window\.alert\s*\(/);
|
||||||
|
assert.doesNotMatch(source, /function QueuePressureGrid|function OperatorJob/);
|
||||||
|
assert.doesNotMatch(source, /humanize\(/);
|
||||||
|
|
||||||
|
const localized = new Map([
|
||||||
|
["i18n:govoplan-campaign.claimed.83c87884", ["Claimed", "Beansprucht"]],
|
||||||
|
["i18n:govoplan-campaign.sending.ceafde86", ["Sending", "Im Versand"]],
|
||||||
|
["i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70", ["Delivery jobs for {value0}", "Versandaufträge für {value0}"]],
|
||||||
|
["i18n:govoplan-campaign.open_recipient_report.d6892ee4", ["Open recipient report", "Empfängerbezogenen Bericht öffnen"]],
|
||||||
|
["i18n:govoplan-campaign.queued_at.1ac179b3", ["Queued at", "Eingereiht am"]],
|
||||||
|
["i18n:govoplan-campaign.sent_at.a6f45556", ["Sent at", "Gesendet am"]],
|
||||||
|
["i18n:govoplan-campaign.current.4fc0e2bc", ["Current", "Aktuell"]],
|
||||||
|
["i18n:govoplan-campaign.historical.bb2fe205", ["Historical", "Historisch"]],
|
||||||
|
["i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf", ["Pause all queued work for this Campaign", "Alle eingereihten Aufträge dieser Kampagne pausieren"]]
|
||||||
|
]);
|
||||||
|
for (const [key, values] of localized) {
|
||||||
|
assert.equal(translations.split(`"${key}"`).length - 1, 2, `${key} occurs once per language`);
|
||||||
|
for (const value of values) assert(translations.includes(`"${key}": ${JSON.stringify(value)}`));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user