diff --git a/webui/src/features/operator/OperatorQueuePage.tsx b/webui/src/features/operator/OperatorQueuePage.tsx index 1b703ed..0db4b4f 100644 --- a/webui/src/features/operator/OperatorQueuePage.tsx +++ b/webui/src/features/operator/OperatorQueuePage.tsx @@ -1,20 +1,30 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Ban, ExternalLink, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react"; +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-dom"; import type { ApiSettings, CampaignListItem } from "../../types"; import { cancelCampaign, + getCampaignJobs, + getCampaignSummary, getCampaignWorkspaceDelta, listCampaignsDelta, pauseCampaign, resumeCampaign, retryCampaignJobs, sendUnattemptedCampaignJobs, - type CampaignSummary + 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 } from "@govoplan/core-webui"; +import { + DataGrid, + type DataGridColumn, + type DataGridListOption, + type DataGridQueryState +} from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui"; @@ -31,7 +41,11 @@ import { } 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, operatorQueueActionBlocks, type OperatorQueueAction, type OperatorQueuePermissions @@ -39,6 +53,8 @@ import { type OperatorRow = { campaign: CampaignListItem; + version: CampaignVersionListItem | null; + isCurrent: boolean; summary: CampaignSummary | null; mode: string | null; modeSelectedAt: string | null; @@ -48,22 +64,64 @@ type OperatorRow = { 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 summariesRef = useRef>>({}); + const versionsRef = useRef>({}); const loadingRef = useRef(false); const backgroundLoadRef = useRef<() => Promise>(async () => undefined); const [loading, setLoading] = useState(true); @@ -71,9 +129,20 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett 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"), @@ -94,26 +163,95 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett }), [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(); }, [settingsKey, resetDeltaWatermark]); useEffect(() => { backgroundLoadRef.current = () => load(true); + backgroundJobsLoadRef.current = () => loadSelectedJobs(true); }); - const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0); useEffect(() => { - if (!hasActiveDelivery) return; + if (!loading && selectedCampaignId && !selectedRow) selectCampaign(""); + }, [loading, selectCampaign, selectedCampaignId, selectedRow]); + + useEffect(() => { + if (!loading && selectedRow && selectedVersionParam !== rowVersionId(selectedRow)) { + selectCampaign(selectedRow.campaign.id, rowVersionId(selectedRow)); + } + }, [loading, selectCampaign, selectedRow, selectedVersionParam]); + + useEffect(() => { + jobsRequestRef.current += 1; + setJobs(emptyCampaignJobsResponse()); + setJobsLoading(false); + setJobsError(""); + setJobPage(1); + setJobGridQuery(DEFAULT_JOB_GRID_QUERY); + setJobsOwnerKey(selectedJobsKey); + }, [selectedJobsKey]); + + const jobGridQueryKey = JSON.stringify(jobGridQuery); + useEffect(() => { + if (jobsOwnerKey === selectedJobsKey && selectedJobsKey) void loadSelectedJobs(); + }, [jobsOwnerKey, selectedJobsKey, jobPage, jobPageSize, jobGridQueryKey, permissions.canRead]); + + const hasActiveDelivery = rows.some((row) => row.queuedOrActive > 0 || row.needsAttention > 0); + const selectedJobsActive = jobs.jobs.some((job) => isActiveJob(job)); + const selectedQueueActive = Boolean( + selectedRow && (selectedRow.queuedOrActive > 0 || selectedRow.needsAttention > 0) + ) || selectedJobsActive; + useEffect(() => { + if (!hasActiveDelivery && !selectedQueueActive) return; const handle = window.setInterval(() => { void backgroundLoadRef.current(); + if (selectedQueueActive) void backgroundJobsLoadRef.current(); }, 10_000); return () => window.clearInterval(handle); - }, [hasActiveDelivery]); + }, [hasActiveDelivery, selectedQueueActive]); + + useEffect(() => { + const handle = window.setInterval(() => { + void backgroundLoadRef.current(); + }, 60_000); + return () => window.clearInterval(handle); + }, []); async function load(background = false) { if (loadingRef.current) return; @@ -122,8 +260,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett setError(""); try { const campaigns = await loadCampaignsDelta(); - const summaries = await loadCampaignSummariesDelta(campaigns); - setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null))); + setRows(await loadCampaignVersionRows(campaigns)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -132,6 +269,37 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett } } + async function loadSelectedJobs(background = false) { + if (!selectedRow || !selectedVersionId || !permissions.canRead || jobsOwnerKey !== selectedJobsKey) return; + const requestId = ++jobsRequestRef.current; + if (!background) setJobsLoading(true); + setJobsError(""); + try { + const response = await getCampaignJobs(settings, selectedRow.campaign.id, { + versionId: selectedVersionId, + page: jobPage, + pageSize: jobPageSize, + sortBy: campaignJobSortColumn(jobGridQuery.sort?.columnId), + sortDirection: jobGridQuery.sort?.direction ?? "asc", + filters: jobGridQuery.filters + }); + if (requestId !== jobsRequestRef.current) return; + setJobs(response); + if (response.pages > 0 && jobPage > response.pages) setJobPage(response.pages); + } catch (err) { + if (requestId === jobsRequestRef.current) { + setJobsError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!background && requestId === jobsRequestRef.current) setJobsLoading(false); + } + } + + async function refreshAll() { + await load(); + await backgroundJobsLoadRef.current(); + } + async function runAction(row: OperatorRow, action: QueueMutationAction) { const key = `${action}:${row.campaign.id}`; setBusy(key); @@ -139,9 +307,15 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett setMessage(""); try { const response = action === "retry" - ? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) + ? await retryCampaignJobs(settings, row.campaign.id, { + version_id: rowVersionId(row), + enqueue_celery: true + }) : action === "queue-unsent" - ? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) + ? await sendUnattemptedCampaignJobs(settings, row.campaign.id, { + version_id: rowVersionId(row), + enqueue_celery: true + }) : action === "pause" ? await pauseCampaign(settings, row.campaign.id) : action === "resume" @@ -157,8 +331,8 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett } )); if (action === "cancel") setCancelTarget(null); - resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id)); - await load(); + resetDeltaWatermark(operatorCampaignWorkspaceKey(row.campaign.id)); + await refreshAll(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -189,92 +363,190 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett return campaigns; } - async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise> { + async function loadCampaignVersionRows(campaigns: CampaignListItem[]): Promise { const summaries = { ...summariesRef.current }; + const versions = { ...versionsRef.current }; await Promise.all(campaigns.map(async (campaign) => { - const key = operatorCampaignSummaryKey(campaign.id); + const key = operatorCampaignWorkspaceKey(campaign.id); let nextWatermark = getDeltaWatermark(key); - let summary = summaries[campaign.id] ?? null; + let campaignVersions = versions[campaign.id] ?? []; let hasMore = false; try { do { const response = await getCampaignWorkspaceDelta(settings, campaign.id, { includeCurrentVersion: false, - includeVersions: false, - includeSummary: true, + includeVersions: true, + includeSummary: false, since: nextWatermark }); - if (response.full || response.summary) summary = response.summary; + campaignVersions = response.full + ? response.versions + : mergeDeltaRows( + campaignVersions, + response.versions, + response.deleted, + (version) => version.id, + { + deletedResourceType: "campaign_version", + sort: sortVersionsNewestFirst + } + ); nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); setDeltaWatermark(key, nextWatermark); - summaries[campaign.id] = summary; + versions[campaign.id] = campaignVersions; + + const campaignSummaries = { ...(summaries[campaign.id] ?? {}) }; + await Promise.all(campaignVersions.map(async (version) => { + try { + campaignSummaries[version.id] = await getCampaignSummary(settings, campaign.id, version.id); + } catch { + 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 { - summaries[campaign.id] = summary; + 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 summaries; + return campaigns.flatMap((campaign) => toVersionRows( + campaign, + versions[campaign.id] ?? [], + summaries[campaign.id] ?? {} + )); } function operatorCampaignListKey(): string { return JSON.stringify({ scope: "operator-campaigns", settingsKey }); } - function operatorCampaignSummaryKey(campaignId: string): string { - return JSON.stringify({ scope: "operator-campaign-summary", campaignId, 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, - queuedOrActive: acc.queuedOrActive + row.queuedOrActive, + queued: acc.queued + row.queued, + claimed: acc.claimed + row.claimed, + sending: acc.sending + row.sending, + completed: acc.completed + row.completed, paused: acc.paused + row.paused, imapFailed: acc.imapFailed + row.imapFailed - }), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, paused: 0, imapFailed: 0 }); + }), { + failed: 0, + outcomeUnknown: 0, + notAttempted: 0, + queued: 0, + claimed: 0, + sending: 0, + completed: 0, + paused: 0, + imapFailed: 0 + }); const columns = useMemo[]>(() => [ { 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: "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_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive }, + { id: "queued", header: "i18n:govoplan-campaign.queued.6a599877", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.queued }, + { id: "claimed", header: "i18n:govoplan-campaign.claimed.83c87884", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.claimed }, + { id: "sending", header: "i18n:govoplan-campaign.sending.ceafde86", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => row.sending }, + { id: "completed", header: "i18n:govoplan-campaign.completed.1798b3ba", width: 115, align: "right", sortable: true, filterType: "integer", value: (row) => row.completed }, { id: "paused", header: "i18n:govoplan-campaign.paused.c7dfb6f1", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.paused }, - { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.campaign.updated_at ?? "" }, + { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at), sortValue: (row) => row.modeSelectedAt ?? row.version?.updated_at ?? row.campaign.updated_at ?? "" }, { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 330, sticky: "end", render: (row) => { - const blocks = operatorQueueActionBlocks(row, permissions); + const blocks = operatorQueueActionBlocks({ + retryable: row.retryable, + outcomeUnknown: row.outcomeUnknown, + queueableUnattempted: row.queueableUnattempted, + pausable: row.campaignPausable, + paused: row.campaignPaused, + cancellable: row.campaignCancellable + }, permissions); const disabledReason = (action: OperatorQueueAction) => 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