import { MetricGrid } from "@govoplan/core-webui"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react"; import { useSearchParams } from "react-router"; import type { ApiSettings, CampaignListItem } from "../../types"; import { cancelCampaign, getCampaignJobs, getCampaignSummary, getCampaignWorkspaceDelta, listCampaignsDelta, pauseCampaign, resumeCampaign, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignJobsResponse, type CampaignSummary, type CampaignVersionListItem } from "../../api/campaigns"; import { Button } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui"; import { DismissibleAlert, PageLayout } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui"; import { StatusBadge, TableActionGroup, hasScope, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate, type AuthInfo } from "@govoplan/core-webui"; import { asRecord, formatDateTime } from "../campaigns/utils/campaignView"; import { deliveryModeLabel } from "../campaigns/utils/deliveryMode"; import { emptyCampaignJobsResponse } from "../campaigns/utils/jobDeltas"; import type { CampaignJobSortColumn } from "../campaigns/utils/jobListQuery"; import { campaignLifecycleTotals, campaignVersionWorkItems, operatorDeliveryStateCounts, operatorQueueActionBlocks, shouldRefreshOperatorVersionSummary, type OperatorQueueAction, type OperatorQueuePermissions } from "./operatorQueueModel"; type OperatorRow = { campaign: CampaignListItem; version: CampaignVersionListItem | null; isCurrent: boolean; summary: CampaignSummary | null; mode: string | null; modeSelectedAt: string | null; failed: number; retryable: number; outcomeUnknown: number; notAttempted: number; queuedOrActive: number; queued: number; claimed: number; sending: number; completed: number; pausable: number; paused: number; campaignPausable: number; campaignPaused: number; imapFailed: number; queueable: number; queueableUnattempted: number; needsAttention: number; cancellable: number; campaignCancellable: number; }; type QueueMutationAction = "pause" | "resume" | "retry" | "queue-unsent" | "cancel"; type CampaignLifecycleFacts = {pausable: number;paused: number;cancellable: number;}; const DEFAULT_JOB_GRID_QUERY: DataGridQueryState = { sort: { columnId: "number", direction: "asc" }, filters: {} }; const QUEUE_STATUS_OPTIONS: DataGridListOption[] = ["draft", "queued", "sending", "paused", "cancelled"] .map((value) => ({ value, label: deliveryStatusOptionLabel(value) })); const SEND_STATUS_OPTIONS: DataGridListOption[] = [ "not_queued", "skipped", "queued", "claimed", "sending", "smtp_accepted", "sent", "outcome_unknown", "failed_temporary", "failed_permanent", "cancelled" ].map((value) => ({ value, label: deliveryStatusOptionLabel(value) })); const IMAP_STATUS_OPTIONS: DataGridListOption[] = [ "not_requested", "pending", "appending", "appended", "outcome_unknown", "failed", "skipped" ].map((value) => ({ value, label: deliveryStatusOptionLabel(value) })); export default function OperatorQueuePage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const navigate = useGuardedNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const selectedCampaignId = searchParams.get("campaign") ?? ""; const selectedVersionParam = searchParams.get("version") ?? ""; const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [rows, setRows] = useState([]); const campaignsRef = useRef([]); const summariesRef = useRef>>({}); const versionsRef = useRef>({}); const loadingRef = useRef(false); const pendingFullDiscoveryRef = useRef(false); const backgroundLoadRef = useRef<() => Promise>(async () => undefined); const fullDiscoveryLoadRef = 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 [jobs, setJobs] = useState(() => emptyCampaignJobsResponse()); const [jobsOwnerKey, setJobsOwnerKey] = useState(""); const [jobsLoading, setJobsLoading] = useState(false); const [jobsError, setJobsError] = useState(""); const [jobPage, setJobPage] = useState(1); const [jobPageSize, setJobPageSize] = useState(50); const [jobGridQuery, setJobGridQuery] = useState(DEFAULT_JOB_GRID_QUERY); const jobsRequestRef = useRef(0); const backgroundJobsLoadRef = useRef<() => Promise>(async () => undefined); const permissions = useMemo(() => ({ canRead: hasScope(auth, "campaigns:campaign:read") && hasScope(auth, "campaigns:recipient:read"), canOpenReport: hasScope(auth, "campaigns:campaign:read") && hasScope(auth, "campaigns:report:read") && hasScope(auth, "campaigns:recipient:read"), canControl: hasScope(auth, "campaigns:campaign:control"), 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({ apiBaseUrl: settings.apiBaseUrl, apiKey: settings.apiKey, accessToken: settings.accessToken }), [settings.apiBaseUrl, settings.apiKey, settings.accessToken] ); const selectedRow = useMemo( () => rows.find((row) => row.campaign.id === selectedCampaignId && ( selectedVersionParam ? rowVersionId(row) === selectedVersionParam : rowVersionId(row) === String(row.campaign.current_version_id ?? "") )) ?? rows.find((row) => row.campaign.id === selectedCampaignId) ?? null, [rows, selectedCampaignId, selectedVersionParam] ); const selectedVersionId = rowVersionId(selectedRow); const selectedJobsKey = selectedRow && selectedVersionId ? `${selectedRow.campaign.id}:${selectedVersionId}:${settingsKey}` : ""; const selectCampaign = useCallback((campaignId: string, versionId = "") => { const next = new URLSearchParams(searchParams); if (campaignId) { next.set("campaign", campaignId); if (versionId) next.set("version", versionId); else next.delete("version"); } else { next.delete("campaign"); next.delete("version"); } setSearchParams(next, { replace: true }); }, [searchParams, setSearchParams]); const handleJobGridQuery = useCallback((next: DataGridQueryState) => { setJobGridQuery((current) => dataGridQueriesEqual(current, next) ? current : next); setJobPage(1); }, []); useEffect(() => { campaignsRef.current = []; summariesRef.current = {}; versionsRef.current = {}; resetDeltaWatermark(); void load(false, true); }, [settingsKey, resetDeltaWatermark]); useEffect(() => { backgroundLoadRef.current = () => load(true, false); fullDiscoveryLoadRef.current = () => load(true, true); backgroundJobsLoadRef.current = () => loadSelectedJobs(true); }); useEffect(() => { if (!loading && selectedCampaignId && !selectedRow) selectCampaign(""); }, [loading, selectCampaign, selectedCampaignId, selectedRow]); useEffect(() => { if (!loading && selectedRow && selectedVersionParam !== rowVersionId(selectedRow)) { selectCampaign(selectedRow.campaign.id, rowVersionId(selectedRow)); } }, [loading, selectCampaign, selectedRow, selectedVersionParam]); useEffect(() => { jobsRequestRef.current += 1; setJobs(emptyCampaignJobsResponse()); setJobsLoading(false); setJobsError(""); setJobPage(1); setJobGridQuery(DEFAULT_JOB_GRID_QUERY); setJobsOwnerKey(selectedJobsKey); }, [selectedJobsKey]); const jobGridQueryKey = JSON.stringify(jobGridQuery); useEffect(() => { if (jobsOwnerKey === selectedJobsKey && selectedJobsKey) void loadSelectedJobs(); }, [jobsOwnerKey, selectedJobsKey, jobPage, jobPageSize, jobGridQueryKey, permissions.canRead]); const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0 || row.needsAttention > 0); const selectedJobsActive = jobs.jobs.some((job) => isActiveJob(job)); const selectedQueueActive = Boolean( selectedRow && (selectedRow.queuedOrActive > 0 || selectedRow.needsAttention > 0) ) || selectedJobsActive; useEffect(() => { if (!hasActiveDelivery && !selectedRow) return; const handle = window.setInterval(() => { void backgroundLoadRef.current(); if (selectedQueueActive) void backgroundJobsLoadRef.current(); }, 10_000); return () => window.clearInterval(handle); }, [hasActiveDelivery, selectedQueueActive, selectedRow]); useEffect(() => { const handle = window.setInterval(() => { void fullDiscoveryLoadRef.current(); }, 60_000); return () => window.clearInterval(handle); }, []); async function load(background = false, fullDiscovery = !background) { if (loadingRef.current) { if (fullDiscovery) pendingFullDiscoveryRef.current = true; return; } loadingRef.current = true; if (!background) setLoading(true); setError(""); try { const campaigns = await loadCampaignsDelta(); setRows(await loadCampaignVersionRows(campaigns, fullDiscovery)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { if (!background) setLoading(false); loadingRef.current = false; if (pendingFullDiscoveryRef.current) { pendingFullDiscoveryRef.current = false; void fullDiscoveryLoadRef.current(); } } } async function loadSelectedJobs(background = false) { if (!selectedRow || !selectedVersionId || !permissions.canRead || jobsOwnerKey !== selectedJobsKey) return; const requestId = ++jobsRequestRef.current; if (!background) setJobsLoading(true); setJobsError(""); try { const response = await getCampaignJobs(settings, selectedRow.campaign.id, { versionId: selectedVersionId, page: jobPage, pageSize: jobPageSize, sortBy: campaignJobSortColumn(jobGridQuery.sort?.columnId), sortDirection: jobGridQuery.sort?.direction ?? "asc", filters: jobGridQuery.filters }); if (requestId !== jobsRequestRef.current) return; setJobs(response); if (response.pages > 0 && jobPage > response.pages) setJobPage(response.pages); } catch (err) { if (requestId === jobsRequestRef.current) { setJobsError(err instanceof Error ? err.message : String(err)); } } finally { if (!background && requestId === jobsRequestRef.current) setJobsLoading(false); } } async function refreshAll() { await load(false, true); await backgroundJobsLoadRef.current(); } 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, { version_id: rowVersionId(row), enqueue_celery: true }) : action === "queue-unsent" ? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { version_id: rowVersionId(row), 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.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(operatorCampaignWorkspaceKey(row.campaign.id)); await refreshAll(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(""); } } async function loadCampaignsDelta(): Promise { const key = operatorCampaignListKey(); let nextWatermark = getDeltaWatermark(key); let campaigns = campaignsRef.current; let hasMore = false; do { const response = await listCampaignsDelta(settings, { since: nextWatermark }); campaigns = mergeDeltaRows(campaigns, response.campaigns, response.deleted, (campaign) => campaign.id, { deletedResourceType: "campaign", sort: sortCampaignsByUpdatedDesc }); nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); const campaignIds = new Set(campaigns.map((campaign) => campaign.id)); for (const campaignId of Object.keys(summariesRef.current)) { if (!campaignIds.has(campaignId)) delete summariesRef.current[campaignId]; } campaignsRef.current = campaigns; setDeltaWatermark(key, nextWatermark); return campaigns; } async function loadCampaignVersionRows(campaigns: CampaignListItem[], fullDiscovery: boolean): Promise { const summaries = { ...summariesRef.current }; const versions = { ...versionsRef.current }; await Promise.all(campaigns.map(async (campaign) => { const key = operatorCampaignWorkspaceKey(campaign.id); let nextWatermark = getDeltaWatermark(key); let campaignVersions = versions[campaign.id] ?? []; const changedVersionIds = new Set(); let hasMore = false; try { do { const response = await getCampaignWorkspaceDelta(settings, campaign.id, { includeCurrentVersion: false, includeVersions: true, includeSummary: false, since: nextWatermark }); for (const version of response.versions) changedVersionIds.add(version.id); campaignVersions = response.full ? response.versions : mergeDeltaRows( campaignVersions, response.versions, response.deleted, (version) => version.id, { deletedResourceType: "campaign_version", sort: sortVersionsNewestFirst } ); nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); setDeltaWatermark(key, nextWatermark); versions[campaign.id] = campaignVersions; const campaignSummaries = { ...(summaries[campaign.id] ?? {}) }; const summariesToRefresh = campaignVersions.filter((version) => { const cached = campaignSummaries[version.id] ?? null; const cards = asRecord(cached?.cards); const statusCounts = asRecord(cached?.status_counts); const queueCounts = asRecord(statusCounts.queue); const sendCounts = asRecord(statusCounts.send); const deliveryState = operatorDeliveryStateCounts( { queued: numberValue(queueCounts.queued) }, { claimed: numberValue(sendCounts.claimed), sending: numberValue(sendCounts.sending) } ); return shouldRefreshOperatorVersionSummary({ fullDiscovery, versionChanged: changedVersionIds.has(version.id), selected: selectedCampaignId === campaign.id && ( selectedVersionParam ? selectedVersionParam === version.id : campaign.current_version_id === version.id ), cached: cached !== null, queuedOrActive: deliveryState.queuedOrActive, needsAttention: numberValue(cards.needs_attention) }); }); await Promise.all(summariesToRefresh.map(async (version) => { try { campaignSummaries[version.id] = await getCampaignSummary(settings, campaign.id, version.id); } catch { campaignSummaries[version.id] = campaignSummaries[version.id] ?? null; } })); const versionIds = new Set(campaignVersions.map((version) => version.id)); for (const versionId of Object.keys(campaignSummaries)) { if (!versionIds.has(versionId)) delete campaignSummaries[versionId]; } summaries[campaign.id] = campaignSummaries; } catch { versions[campaign.id] = campaignVersions; } })); const campaignIds = new Set(campaigns.map((campaign) => campaign.id)); for (const campaignId of Object.keys(versions)) { if (!campaignIds.has(campaignId)) { delete versions[campaignId]; delete summaries[campaignId]; } } versionsRef.current = versions; summariesRef.current = summaries; return campaigns.flatMap((campaign) => toVersionRows( campaign, versions[campaign.id] ?? [], summaries[campaign.id] ?? {} )); } function operatorCampaignListKey(): string { return JSON.stringify({ scope: "operator-campaigns", settingsKey }); } function operatorCampaignWorkspaceKey(campaignId: string): string { return JSON.stringify({ scope: "operator-campaign-workspace", campaignId, settingsKey }); } const totals = rows.reduce((acc, row) => ({ failed: acc.failed + row.failed, outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown, notAttempted: acc.notAttempted + row.notAttempted, queued: acc.queued + row.queued, claimed: acc.claimed + row.claimed, sending: acc.sending + row.sending, completed: acc.completed + row.completed, paused: acc.paused + row.paused, imapFailed: acc.imapFailed + row.imapFailed }), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queued: 0, claimed: 0, sending: 0, completed: 0, paused: 0, imapFailed: 0 }); const columns = useMemo[]>(() => [ { id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", maxWidth: 680, 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: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 155, sortable: true, render: (row) => (
{campaignVersionIdentity(row)}
), value: (row) => campaignVersionIdentity(row), sortValue: (row) => row.version?.version_number ?? row.summary?.current_version?.version_number ?? -1 }, { id: "mode", header: "i18n:govoplan-campaign.mode.a7b93d21", width: 155, sortable: true, filterable: true, value: (row) => deliveryModeLabel(row.mode), sortValue: (row) => row.mode ?? "" }, { id: "attention", header: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention }, { id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed }, { id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown }, { id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted }, { id: "queued", header: "i18n:govoplan-campaign.queued.6a599877", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.queued }, { id: "claimed", header: "i18n:govoplan-campaign.claimed.83c87884", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.claimed }, { id: "sending", header: "i18n:govoplan-campaign.sending.ceafde86", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.sending }, { id: "completed", header: "i18n:govoplan-campaign.completed.1798b3ba", width: 115, align: "right", sortable: true, filterType: "integer", value: (row) => row.completed }, { id: "paused", header: "i18n:govoplan-campaign.paused.c7dfb6f1", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.paused }, { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at ?? "" }, { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 330, sticky: "end", render: (row) => { const blocks = operatorQueueActionBlocks({ retryable: row.retryable, outcomeUnknown: row.outcomeUnknown, queueableUnattempted: row.queueableUnattempted, pausable: row.campaignPausable, paused: row.campaignPaused, cancellable: row.campaignCancellable }, permissions); const disabledReason = (action: OperatorQueueAction) => blocks[action] ?? (busy && action !== "details" ? "i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea" : undefined); const detailsDisabledReason = disabledReason("details") ?? (!rowVersionId(row) ? "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b" : row.campaign.id === selectedCampaignId && rowVersionId(row) === selectedVersionParam ? "i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8" : undefined); const reportDisabledReason = disabledReason("report") ?? (!rowVersionId(row) ? "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b" : undefined); return