feat(campaign): add durable operator queue controls
This commit is contained in:
@@ -290,6 +290,10 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
|
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
|
||||||
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
|
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
|
||||||
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||||
|
),
|
||||||
related_modules=("mail", "notifications"),
|
related_modules=("mail", "notifications"),
|
||||||
),
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
|
|||||||
@@ -194,12 +194,12 @@ manifest = ModuleManifest(
|
|||||||
path="/operator",
|
path="/operator",
|
||||||
label="Operator Queue",
|
label="Operator Queue",
|
||||||
icon="radio-tower",
|
icon="radio-tower",
|
||||||
|
required_all=("campaigns:campaign:read",),
|
||||||
required_any=(
|
required_any=(
|
||||||
"campaigns:campaign:queue",
|
"campaigns:campaign:queue",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
"campaigns:campaign:reconcile",
|
"campaigns:campaign:reconcile",
|
||||||
"campaigns:campaign:control",
|
"campaigns:campaign:control",
|
||||||
"campaigns:campaign:send",
|
|
||||||
),
|
),
|
||||||
order=30,
|
order=30,
|
||||||
),
|
),
|
||||||
@@ -214,12 +214,12 @@ manifest = ModuleManifest(
|
|||||||
path="/operator",
|
path="/operator",
|
||||||
label="Operator Queue",
|
label="Operator Queue",
|
||||||
icon="radio-tower",
|
icon="radio-tower",
|
||||||
|
required_all=("campaigns:campaign:read",),
|
||||||
required_any=(
|
required_any=(
|
||||||
"campaigns:campaign:queue",
|
"campaigns:campaign:queue",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
"campaigns:campaign:reconcile",
|
"campaigns:campaign:reconcile",
|
||||||
"campaigns:campaign:control",
|
"campaigns:campaign:control",
|
||||||
"campaigns:campaign:send",
|
|
||||||
),
|
),
|
||||||
order=30,
|
order=30,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -284,6 +284,12 @@ def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_auth
|
|||||||
assert "campaigns.workflow.queue-delivery" in with_mail
|
assert "campaigns.workflow.queue-delivery" in with_mail
|
||||||
assert "campaigns.workflow.send-small-controlled-run" in with_mail
|
assert "campaigns.workflow.send-small-controlled-run" in with_mail
|
||||||
|
|
||||||
|
queue_topic = next(
|
||||||
|
topic for topic in CAMPAIGN_USER_DOCUMENTATION
|
||||||
|
if topic.id == "campaigns.workflow.queue-delivery"
|
||||||
|
)
|
||||||
|
assert any(link.href == "/operator" for link in queue_topic.links)
|
||||||
|
|
||||||
|
|
||||||
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
|
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
|
||||||
attachment_scopes = {
|
attachment_scopes = {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
||||||
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
|
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
|
||||||
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
|
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
|
||||||
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs"
|
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
|
||||||
|
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
@@ -1,39 +1,89 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExternalLink, RotateCcw, Send } from "lucide-react";
|
import { Ban, ExternalLink, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
|
||||||
import type { ApiSettings, CampaignListItem } from "../../types";
|
import type { ApiSettings, CampaignListItem } from "../../types";
|
||||||
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
import {
|
||||||
|
cancelCampaign,
|
||||||
|
getCampaignWorkspaceDelta,
|
||||||
|
listCampaignsDelta,
|
||||||
|
pauseCampaign,
|
||||||
|
resumeCampaign,
|
||||||
|
retryCampaignJobs,
|
||||||
|
sendUnattemptedCampaignJobs,
|
||||||
|
type CampaignSummary
|
||||||
|
} 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 { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } 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";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { StatusBadge, TableActionGroup, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
|
import {
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
mergeDeltaRows,
|
||||||
|
useDeltaWatermarks,
|
||||||
|
useGuardedNavigate,
|
||||||
|
type AuthInfo
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
||||||
|
import {
|
||||||
|
operatorQueueActionBlocks,
|
||||||
|
type OperatorQueueAction,
|
||||||
|
type OperatorQueuePermissions
|
||||||
|
} from "./operatorQueueModel";
|
||||||
|
|
||||||
type OperatorRow = {
|
type OperatorRow = {
|
||||||
campaign: CampaignListItem;
|
campaign: CampaignListItem;
|
||||||
summary: CampaignSummary | null;
|
summary: CampaignSummary | null;
|
||||||
|
mode: string | null;
|
||||||
|
modeSelectedAt: string | null;
|
||||||
failed: number;
|
failed: number;
|
||||||
|
retryable: number;
|
||||||
outcomeUnknown: number;
|
outcomeUnknown: number;
|
||||||
notAttempted: number;
|
notAttempted: number;
|
||||||
queuedOrActive: number;
|
queuedOrActive: number;
|
||||||
|
queued: number;
|
||||||
|
paused: number;
|
||||||
imapFailed: number;
|
imapFailed: number;
|
||||||
queueable: number;
|
queueable: number;
|
||||||
|
queueableUnattempted: number;
|
||||||
needsAttention: number;
|
needsAttention: number;
|
||||||
|
cancellable: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
|
type QueueMutationAction = "pause" | "resume" | "retry" | "queue-unsent" | "cancel";
|
||||||
|
|
||||||
|
export default function OperatorQueuePage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
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, CampaignSummary | null>>({});
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
|
const [cancelTarget, setCancelTarget] = useState<OperatorRow | null>(null);
|
||||||
|
|
||||||
|
const permissions = useMemo<OperatorQueuePermissions>(() => ({
|
||||||
|
canRead: hasScope(auth, "campaigns:campaign:read")
|
||||||
|
&& hasScope(auth, "campaigns:report:read")
|
||||||
|
&& hasScope(auth, "campaigns:recipient:read"),
|
||||||
|
canControl: hasScope(auth, "campaigns:campaign:control"),
|
||||||
|
canRetry: hasScope(auth, "campaigns:campaign:retry")
|
||||||
|
&& hasScope(auth, "campaigns:recipient:read"),
|
||||||
|
canQueue: hasScope(auth, "campaigns:campaign:queue")
|
||||||
|
&& hasScope(auth, "campaigns:recipient:read"),
|
||||||
|
canReconcile: hasScope(auth, "campaigns:campaign:reconcile")
|
||||||
|
&& hasScope(auth, "campaigns:report:read")
|
||||||
|
&& hasScope(auth, "campaigns:recipient:read")
|
||||||
|
}), [auth]);
|
||||||
|
|
||||||
const settingsKey = useMemo(
|
const settingsKey = useMemo(
|
||||||
() => JSON.stringify({
|
() => JSON.stringify({
|
||||||
@@ -51,8 +101,23 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
void load();
|
void load();
|
||||||
}, [settingsKey, resetDeltaWatermark]);
|
}, [settingsKey, resetDeltaWatermark]);
|
||||||
|
|
||||||
async function load() {
|
useEffect(() => {
|
||||||
setLoading(true);
|
backgroundLoadRef.current = () => load(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveDelivery) return;
|
||||||
|
const handle = window.setInterval(() => {
|
||||||
|
void backgroundLoadRef.current();
|
||||||
|
}, 10_000);
|
||||||
|
return () => window.clearInterval(handle);
|
||||||
|
}, [hasActiveDelivery]);
|
||||||
|
|
||||||
|
async function load(background = false) {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
if (!background) setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const campaigns = await loadCampaignsDelta();
|
const campaigns = await loadCampaignsDelta();
|
||||||
@@ -61,21 +126,36 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!background) setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAction(row: OperatorRow, action: "retry" | "unattempted") {
|
async function runAction(row: OperatorRow, action: QueueMutationAction) {
|
||||||
const key = `${action}:${row.campaign.id}`;
|
const key = `${action}:${row.campaign.id}`;
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
setError("");
|
setError("");
|
||||||
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, { enqueue_celery: true })
|
||||||
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
: action === "queue-unsent"
|
||||||
|
? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
|
||||||
|
: action === "pause"
|
||||||
|
? await pauseCampaign(settings, row.campaign.id)
|
||||||
|
: action === "resume"
|
||||||
|
? await resumeCampaign(settings, row.campaign.id)
|
||||||
|
: await cancelCampaign(settings, row.campaign.id);
|
||||||
const result = asRecord(response.result ?? response);
|
const result = asRecord(response.result ?? response);
|
||||||
setMessage(i18nMessage("i18n:govoplan-campaign.value_value_value_enqueued.35b33f6d", { value0: row.campaign.name, value1: humanize(String(result.action ?? action)), value2: String(result.enqueued_count ?? 0) }));
|
setMessage(i18nMessage(
|
||||||
|
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56",
|
||||||
|
{
|
||||||
|
value0: row.campaign.name,
|
||||||
|
value1: actionLabel(action),
|
||||||
|
value2: String(actionChangedCount(action, result))
|
||||||
|
}
|
||||||
|
));
|
||||||
|
if (action === "cancel") setCancelTarget(null);
|
||||||
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
|
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -150,31 +230,88 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
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,
|
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
|
||||||
|
paused: acc.paused + row.paused,
|
||||||
imapFailed: acc.imapFailed + row.imapFailed
|
imapFailed: acc.imapFailed + row.imapFailed
|
||||||
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 });
|
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 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: "mode", header: "i18n:govoplan-campaign.mode.a7b93d21", width: 155, sortable: true, filterable: true, value: (row) => row.mode ? humanize(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_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
|
||||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
|
{ 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: "actions",
|
id: "actions",
|
||||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||||
width: 150,
|
width: 330,
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => <TableActionGroup actions={[
|
render: (row) => {
|
||||||
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }), icon: <ExternalLink aria-hidden="true" />, onClick: () => navigate(`/campaigns/${row.campaign.id}/report`) },
|
const blocks = operatorQueueActionBlocks(row, permissions);
|
||||||
{ id: "retry", label: "i18n:govoplan-campaign.retry.9f5cd8a2", icon: <RotateCcw aria-hidden="true" />, applicable: row.failed > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "retry") },
|
const disabledReason = (action: OperatorQueueAction) =>
|
||||||
{ id: "queue-unsent", label: "i18n:govoplan-campaign.queue_unsent.b0e98610", icon: <Send aria-hidden="true" />, applicable: row.notAttempted > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "unattempted") }
|
blocks[action] ?? (busy && action !== "details"
|
||||||
]} />
|
? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea"
|
||||||
|
: undefined);
|
||||||
|
return <TableActionGroup actions={[
|
||||||
|
{
|
||||||
|
id: "details",
|
||||||
|
label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }),
|
||||||
|
icon: <ExternalLink aria-hidden="true" />,
|
||||||
|
disabledReason: disabledReason("details"),
|
||||||
|
onClick: () => navigate(`/campaigns/${row.campaign.id}/report`)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pause",
|
||||||
|
label: "i18n:govoplan-campaign.pause_queue.b66e7696",
|
||||||
|
icon: <Pause aria-hidden="true" />,
|
||||||
|
disabledReason: disabledReason("pause"),
|
||||||
|
onClick: () => void runAction(row, "pause")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resume",
|
||||||
|
label: "i18n:govoplan-campaign.resume_queue.1f22f964",
|
||||||
|
icon: <Play aria-hidden="true" />,
|
||||||
|
disabledReason: disabledReason("resume"),
|
||||||
|
onClick: () => void runAction(row, "resume")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "retry",
|
||||||
|
label: "i18n:govoplan-campaign.retry.9f5cd8a2",
|
||||||
|
icon: <RotateCcw aria-hidden="true" />,
|
||||||
|
disabledReason: disabledReason("retry"),
|
||||||
|
onClick: () => void runAction(row, "retry")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "queue-unsent",
|
||||||
|
label: "i18n:govoplan-campaign.queue_unsent.b0e98610",
|
||||||
|
icon: <Send aria-hidden="true" />,
|
||||||
|
disabledReason: disabledReason("queue-unsent"),
|
||||||
|
onClick: () => void runAction(row, "queue-unsent")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reconcile",
|
||||||
|
label: "i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7",
|
||||||
|
icon: <ShieldCheck aria-hidden="true" />,
|
||||||
|
disabledReason: disabledReason("reconcile"),
|
||||||
|
onClick: () => navigate(`/campaigns/${row.campaign.id}/report?send_status=outcome_unknown`)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cancel",
|
||||||
|
label: "i18n:govoplan-campaign.cancel_queued_work.a54fb955",
|
||||||
|
icon: <Ban aria-hidden="true" />,
|
||||||
|
variant: "danger",
|
||||||
|
disabledReason: disabledReason("cancel"),
|
||||||
|
onClick: () => setCancelTarget(row)
|
||||||
|
}
|
||||||
|
]} />;
|
||||||
|
}
|
||||||
|
|
||||||
}],
|
}],
|
||||||
[busy, navigate]);
|
[busy, navigate, permissions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
@@ -182,6 +319,7 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
<div>
|
<div>
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
|
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
|
||||||
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
|
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</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 load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
|
||||||
@@ -198,6 +336,7 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
|
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
|
||||||
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
|
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
|
||||||
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
|
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
|
||||||
|
{ label: "i18n:govoplan-campaign.paused.c7dfb6f1", value: totals.paused, tone: "neutral" },
|
||||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
|
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
|
||||||
} />
|
} />
|
||||||
</section>
|
</section>
|
||||||
@@ -214,22 +353,46 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
|||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(cancelTarget)}
|
||||||
|
title="i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e"
|
||||||
|
message="i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb"
|
||||||
|
confirmLabel="i18n:govoplan-campaign.cancel_queued_work.a54fb955"
|
||||||
|
tone="danger"
|
||||||
|
busy={Boolean(busy)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (cancelTarget) void runAction(cancelTarget, "cancel");
|
||||||
|
}}
|
||||||
|
onCancel={() => setCancelTarget(null)} />
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
|
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
|
||||||
const cards = asRecord(summary?.cards);
|
const cards = asRecord(summary?.cards);
|
||||||
|
const statusCounts = asRecord(summary?.status_counts);
|
||||||
|
const queueCounts = asRecord(statusCounts.queue);
|
||||||
|
const queued = numberValue(queueCounts.queued);
|
||||||
|
const paused = numberValue(queueCounts.paused);
|
||||||
|
const failed = numberValue(cards.failed);
|
||||||
|
const notAttempted = numberValue(cards.not_attempted);
|
||||||
return {
|
return {
|
||||||
campaign,
|
campaign,
|
||||||
summary,
|
summary,
|
||||||
failed: numberValue(cards.failed),
|
mode: summary?.current_version?.delivery_mode ?? null,
|
||||||
|
modeSelectedAt: summary?.current_version?.delivery_mode_selected_at ?? null,
|
||||||
|
failed,
|
||||||
|
retryable: numberValue(cards.retryable),
|
||||||
outcomeUnknown: numberValue(cards.outcome_unknown),
|
outcomeUnknown: numberValue(cards.outcome_unknown),
|
||||||
notAttempted: numberValue(cards.not_attempted),
|
notAttempted,
|
||||||
queuedOrActive: numberValue(cards.queued_or_active),
|
queuedOrActive: numberValue(cards.queued_or_active),
|
||||||
|
queued,
|
||||||
|
paused,
|
||||||
imapFailed: numberValue(cards.imap_failed),
|
imapFailed: numberValue(cards.imap_failed),
|
||||||
queueable: numberValue(cards.queueable),
|
queueable: numberValue(cards.queueable),
|
||||||
needsAttention: numberValue(cards.needs_attention)
|
queueableUnattempted: numberValue(cards.queueable_unattempted),
|
||||||
|
needsAttention: numberValue(cards.needs_attention),
|
||||||
|
cancellable: numberValue(cards.cancellable)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +400,21 @@ function numberValue(value: unknown): number {
|
|||||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: QueueMutationAction): string {
|
||||||
|
if (action === "pause") return "i18n:govoplan-campaign.pause_queue.b66e7696";
|
||||||
|
if (action === "resume") return "i18n:govoplan-campaign.resume_queue.1f22f964";
|
||||||
|
if (action === "retry") return "i18n:govoplan-campaign.retry.9f5cd8a2";
|
||||||
|
if (action === "queue-unsent") return "i18n:govoplan-campaign.queue_unsent.b0e98610";
|
||||||
|
return "i18n:govoplan-campaign.cancel_queued_work.a54fb955";
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionChangedCount(action: QueueMutationAction, result: Record<string, unknown>): number {
|
||||||
|
if (action === "pause") return numberValue(result.paused_count);
|
||||||
|
if (action === "resume") return numberValue(result.resumed_count);
|
||||||
|
if (action === "cancel") return numberValue(result.cancelled_count);
|
||||||
|
return numberValue(result.enqueued_count ?? result.selected_count);
|
||||||
|
}
|
||||||
|
|
||||||
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
|
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
|
||||||
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
||||||
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
|
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
|
||||||
|
|||||||
78
webui/src/features/operator/operatorQueueModel.ts
Normal file
78
webui/src/features/operator/operatorQueueModel.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type OperatorQueueAction =
|
||||||
|
| "details"
|
||||||
|
| "pause"
|
||||||
|
| "resume"
|
||||||
|
| "retry"
|
||||||
|
| "queue-unsent"
|
||||||
|
| "reconcile"
|
||||||
|
| "cancel";
|
||||||
|
|
||||||
|
export type OperatorQueuePermissions = {
|
||||||
|
canRead: boolean;
|
||||||
|
canControl: boolean;
|
||||||
|
canRetry: boolean;
|
||||||
|
canQueue: boolean;
|
||||||
|
canReconcile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperatorQueueFacts = {
|
||||||
|
retryable: number;
|
||||||
|
outcomeUnknown: number;
|
||||||
|
queueableUnattempted: number;
|
||||||
|
queued: number;
|
||||||
|
paused: number;
|
||||||
|
cancellable: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPERATOR_QUEUE_REASON = {
|
||||||
|
permission: "i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6",
|
||||||
|
noQueued: "i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e",
|
||||||
|
noPaused: "i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275",
|
||||||
|
noFailed: "i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9",
|
||||||
|
noUnsent: "i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87",
|
||||||
|
noUnknown: "i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c",
|
||||||
|
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the reason each fixed queue action is unavailable. A null result means
|
||||||
|
* the action is permitted and meaningful for the current queue projection.
|
||||||
|
*/
|
||||||
|
export function operatorQueueActionBlocks(
|
||||||
|
facts: OperatorQueueFacts,
|
||||||
|
permissions: OperatorQueuePermissions
|
||||||
|
): Record<OperatorQueueAction, string | null> {
|
||||||
|
return {
|
||||||
|
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permission,
|
||||||
|
pause: !permissions.canControl
|
||||||
|
? OPERATOR_QUEUE_REASON.permission
|
||||||
|
: facts.queued > 0
|
||||||
|
? null
|
||||||
|
: OPERATOR_QUEUE_REASON.noQueued,
|
||||||
|
resume: !permissions.canControl
|
||||||
|
? OPERATOR_QUEUE_REASON.permission
|
||||||
|
: facts.paused > 0
|
||||||
|
? null
|
||||||
|
: OPERATOR_QUEUE_REASON.noPaused,
|
||||||
|
retry: !permissions.canRetry
|
||||||
|
? OPERATOR_QUEUE_REASON.permission
|
||||||
|
: facts.retryable > 0
|
||||||
|
? null
|
||||||
|
: OPERATOR_QUEUE_REASON.noFailed,
|
||||||
|
"queue-unsent": !permissions.canQueue
|
||||||
|
? OPERATOR_QUEUE_REASON.permission
|
||||||
|
: facts.queueableUnattempted > 0
|
||||||
|
? null
|
||||||
|
: OPERATOR_QUEUE_REASON.noUnsent,
|
||||||
|
reconcile: !permissions.canReconcile
|
||||||
|
? OPERATOR_QUEUE_REASON.permission
|
||||||
|
: facts.outcomeUnknown > 0
|
||||||
|
? null
|
||||||
|
: OPERATOR_QUEUE_REASON.noUnknown,
|
||||||
|
cancel: !permissions.canControl
|
||||||
|
? OPERATOR_QUEUE_REASON.permission
|
||||||
|
: facts.cancellable > 0
|
||||||
|
? null
|
||||||
|
: OPERATOR_QUEUE_REASON.noCancellable
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,23 @@ 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.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_queued_work.a54fb955": "Cancel queued work",
|
||||||
|
"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.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_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_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.pause_queue.b66e7696": "Pause queue",
|
||||||
|
"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_refreshes_automatically_while_delivery_is_active.df8dc205": "Queue refreshes automatically while delivery is active.",
|
||||||
|
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
|
||||||
|
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
|
||||||
|
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
|
||||||
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Campaign-scoped Mail profile",
|
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Campaign-scoped Mail profile",
|
||||||
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign stores only this stable profile reference. The Mail module owns, encrypts, authorizes, tests, and resolves all SMTP/IMAP settings and credentials.",
|
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign stores only this stable profile reference. The Mail module owns, encrypts, authorizes, tests, and resolves all SMTP/IMAP settings and credentials.",
|
||||||
"i18n:govoplan-campaign.configured.668c5fff": "Configured",
|
"i18n:govoplan-campaign.configured.668c5fff": "Configured",
|
||||||
@@ -1176,6 +1193,23 @@ 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.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_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
|
||||||
|
"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.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_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_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.pause_queue.b66e7696": "Warteschlange pausieren",
|
||||||
|
"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_refreshes_automatically_while_delivery_is_active.df8dc205": "Die Warteschlange wird während eines laufenden Versands automatisch aktualisiert.",
|
||||||
|
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
|
||||||
|
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
|
||||||
|
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
|
||||||
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Kampagnenspezifisches Mail-Profil",
|
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Kampagnenspezifisches Mail-Profil",
|
||||||
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign speichert nur diese stabile Profilreferenz. Das Mail-Modul verwaltet, verschlüsselt, autorisiert, testet und löst alle SMTP-/IMAP-Einstellungen und Zugangsdaten auf.",
|
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign speichert nur diese stabile Profilreferenz. Das Mail-Modul verwaltet, verschlüsselt, autorisiert, testet und löst alle SMTP-/IMAP-Einstellungen und Zugangsdaten auf.",
|
||||||
"i18n:govoplan-campaign.configured.668c5fff": "Konfiguriert",
|
"i18n:govoplan-campaign.configured.668c5fff": "Konfiguriert",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePa
|
|||||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||||
|
|
||||||
const campaignRead = ["campaigns:campaign:read"];
|
const campaignRead = ["campaigns:campaign:read"];
|
||||||
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
|
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control"];
|
||||||
const translations = {
|
const translations = {
|
||||||
en: generatedTranslations.en,
|
en: generatedTranslations.en,
|
||||||
de: generatedTranslations.de
|
de: generatedTranslations.de
|
||||||
@@ -31,6 +31,7 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
|
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
|
||||||
iconName: "radio-tower",
|
iconName: "radio-tower",
|
||||||
anyOf: operatorScopes,
|
anyOf: operatorScopes,
|
||||||
|
allOf: campaignRead,
|
||||||
order: 30
|
order: 30
|
||||||
},
|
},
|
||||||
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
|
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
|
||||||
@@ -39,7 +40,7 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
|
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
|
||||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||||
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
|
||||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
||||||
|
|
||||||
|
|||||||
71
webui/tests/operator-queue-model.test.ts
Normal file
71
webui/tests/operator-queue-model.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OPERATOR_QUEUE_REASON,
|
||||||
|
operatorQueueActionBlocks,
|
||||||
|
type OperatorQueueFacts,
|
||||||
|
type OperatorQueuePermissions
|
||||||
|
} from "../src/features/operator/operatorQueueModel.ts";
|
||||||
|
|
||||||
|
const everything: OperatorQueueFacts = {
|
||||||
|
retryable: 2,
|
||||||
|
outcomeUnknown: 1,
|
||||||
|
queueableUnattempted: 3,
|
||||||
|
queued: 4,
|
||||||
|
paused: 5,
|
||||||
|
cancellable: 14
|
||||||
|
};
|
||||||
|
|
||||||
|
const allPermissions: OperatorQueuePermissions = {
|
||||||
|
canRead: true,
|
||||||
|
canControl: true,
|
||||||
|
canRetry: true,
|
||||||
|
canQueue: true,
|
||||||
|
canReconcile: true
|
||||||
|
};
|
||||||
|
|
||||||
|
test("enables every fixed queue action when state and permission allow it", () => {
|
||||||
|
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
|
||||||
|
details: null,
|
||||||
|
pause: null,
|
||||||
|
resume: null,
|
||||||
|
retry: null,
|
||||||
|
"queue-unsent": null,
|
||||||
|
reconcile: null,
|
||||||
|
cancel: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains every unavailable action with a state-specific explanation", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
operatorQueueActionBlocks(
|
||||||
|
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, queued: 0, paused: 0, cancellable: 0 },
|
||||||
|
allPermissions
|
||||||
|
),
|
||||||
|
{
|
||||||
|
details: null,
|
||||||
|
pause: OPERATOR_QUEUE_REASON.noQueued,
|
||||||
|
resume: OPERATOR_QUEUE_REASON.noPaused,
|
||||||
|
retry: OPERATOR_QUEUE_REASON.noFailed,
|
||||||
|
"queue-unsent": OPERATOR_QUEUE_REASON.noUnsent,
|
||||||
|
reconcile: OPERATOR_QUEUE_REASON.noUnknown,
|
||||||
|
cancel: OPERATOR_QUEUE_REASON.noCancellable
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permission explanations take precedence over queue state", () => {
|
||||||
|
const noPermissions: OperatorQueuePermissions = {
|
||||||
|
canRead: false,
|
||||||
|
canControl: false,
|
||||||
|
canRetry: false,
|
||||||
|
canQueue: false,
|
||||||
|
canReconcile: false
|
||||||
|
};
|
||||||
|
const blocks = operatorQueueActionBlocks(everything, noPermissions);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.values(blocks),
|
||||||
|
Array.from({ length: 7 }, () => OPERATOR_QUEUE_REASON.permission)
|
||||||
|
);
|
||||||
|
});
|
||||||
18
webui/tests/operator-queue-ui-structure.test.mjs
Normal file
18
webui/tests/operator-queue-ui-structure.test.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /<DataGrid/);
|
||||||
|
assert.match(source, /<TableActionGroup actions=/);
|
||||||
|
for (const action of ["details", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel"]) {
|
||||||
|
assert.match(source, new RegExp(`id: "${action}"`));
|
||||||
|
}
|
||||||
|
assert.match(source, /useDeltaWatermarks/);
|
||||||
|
assert.match(source, /window\.setInterval[\s\S]*10_000/);
|
||||||
|
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
|
||||||
|
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision/);
|
||||||
|
assert.doesNotMatch(source, /window\.alert\s*\(/);
|
||||||
Reference in New Issue
Block a user