feat(campaign): add durable operator queue controls
This commit is contained in:
@@ -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: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: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": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -1,39 +1,89 @@
|
||||
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 { 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 { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } 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 {
|
||||
operatorQueueActionBlocks,
|
||||
type OperatorQueueAction,
|
||||
type OperatorQueuePermissions
|
||||
} from "./operatorQueueModel";
|
||||
|
||||
type OperatorRow = {
|
||||
campaign: CampaignListItem;
|
||||
summary: CampaignSummary | null;
|
||||
mode: string | null;
|
||||
modeSelectedAt: string | null;
|
||||
failed: number;
|
||||
retryable: number;
|
||||
outcomeUnknown: number;
|
||||
notAttempted: number;
|
||||
queuedOrActive: number;
|
||||
queued: number;
|
||||
paused: number;
|
||||
imapFailed: number;
|
||||
queueable: number;
|
||||
queueableUnattempted: 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 { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [rows, setRows] = useState<OperatorRow[]>([]);
|
||||
const campaignsRef = useRef<CampaignListItem[]>([]);
|
||||
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
|
||||
const loadingRef = useRef(false);
|
||||
const backgroundLoadRef = useRef<() => Promise<void>>(async () => undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = 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(
|
||||
() => JSON.stringify({
|
||||
@@ -51,8 +101,23 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
||||
void load();
|
||||
}, [settingsKey, resetDeltaWatermark]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
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("");
|
||||
try {
|
||||
const campaigns = await loadCampaignsDelta();
|
||||
@@ -61,21 +126,36 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} 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}`;
|
||||
setBusy(key);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = action === "retry" ?
|
||||
await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) :
|
||||
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
||||
const response = action === "retry"
|
||||
? await retryCampaignJobs(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);
|
||||
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));
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -150,31 +230,88 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
||||
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
|
||||
notAttempted: acc.notAttempted + row.notAttempted,
|
||||
queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
|
||||
paused: acc.paused + row.paused,
|
||||
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>[]>(() => [
|
||||
{ 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: "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: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
|
||||
{ id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
|
||||
{ id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
|
||||
{ id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
|
||||
{ id: "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",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 150,
|
||||
width: 330,
|
||||
sticky: "end",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ 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`) },
|
||||
{ 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") },
|
||||
{ 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") }
|
||||
]} />
|
||||
render: (row) => {
|
||||
const blocks = operatorQueueActionBlocks(row, permissions);
|
||||
const disabledReason = (action: OperatorQueueAction) =>
|
||||
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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
@@ -182,6 +319,7 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
||||
<div>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
|
||||
<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 className="button-row compact-actions">
|
||||
<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.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "i18n:govoplan-campaign.paused.c7dfb6f1", value: totals.paused, tone: "neutral" },
|
||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
|
||||
} />
|
||||
</section>
|
||||
@@ -214,22 +353,46 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
|
||||
|
||||
</Card>
|
||||
</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>);
|
||||
|
||||
}
|
||||
|
||||
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
|
||||
const cards = asRecord(summary?.cards);
|
||||
const statusCounts = asRecord(summary?.status_counts);
|
||||
const queueCounts = asRecord(statusCounts.queue);
|
||||
const queued = numberValue(queueCounts.queued);
|
||||
const paused = numberValue(queueCounts.paused);
|
||||
const failed = numberValue(cards.failed);
|
||||
const notAttempted = numberValue(cards.not_attempted);
|
||||
return {
|
||||
campaign,
|
||||
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),
|
||||
notAttempted: numberValue(cards.not_attempted),
|
||||
notAttempted,
|
||||
queuedOrActive: numberValue(cards.queued_or_active),
|
||||
queued,
|
||||
paused,
|
||||
imapFailed: numberValue(cards.imap_failed),
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
|
||||
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": {
|
||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||
"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_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",
|
||||
@@ -1176,6 +1193,23 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"de": {
|
||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||
"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_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",
|
||||
|
||||
@@ -11,7 +11,7 @@ const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePa
|
||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
|
||||
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 = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
@@ -31,6 +31,7 @@ export const campaignModule: PlatformWebModule = {
|
||||
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
|
||||
iconName: "radio-tower",
|
||||
anyOf: operatorScopes,
|
||||
allOf: campaignRead,
|
||||
order: 30
|
||||
},
|
||||
{ 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: [
|
||||
{ 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: "/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: "/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