diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 7805dec..6e64827 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -290,6 +290,10 @@ CAMPAIGN_USER_DOCUMENTATION = ( 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.", 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"), ), _workflow_topic( diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 12c3be6..7e2453a 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -194,12 +194,12 @@ manifest = ModuleManifest( path="/operator", label="Operator Queue", icon="radio-tower", + required_all=("campaigns:campaign:read",), required_any=( "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", - "campaigns:campaign:send", ), order=30, ), @@ -214,12 +214,12 @@ manifest = ModuleManifest( path="/operator", label="Operator Queue", icon="radio-tower", + required_all=("campaigns:campaign:read",), required_any=( "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", - "campaigns:campaign:send", ), order=30, ), diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 6dfbc5d..80eb48f 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -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.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: attachment_scopes = { diff --git a/webui/package.json b/webui/package.json index c545811..32b35d6 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" diff --git a/webui/src/features/operator/OperatorQueuePage.tsx b/webui/src/features/operator/OperatorQueuePage.tsx index df8cb91..83a558b 100644 --- a/webui/src/features/operator/OperatorQueuePage.tsx +++ b/webui/src/features/operator/OperatorQueuePage.tsx @@ -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([]); const campaignsRef = useRef([]); const summariesRef = useRef>({}); + const loadingRef = useRef(false); + const backgroundLoadRef = useRef<() => Promise>(async () => undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [message, setMessage] = useState(""); const [busy, setBusy] = useState(""); + const [cancelTarget, setCancelTarget] = useState(null); + + const permissions = useMemo(() => ({ + 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[]>(() => [ { 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) => , 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) =>