Files
govoplan-campaign/webui/src/features/operator/OperatorQueuePage.tsx

258 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink } from "lucide-react";
import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
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, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = {
campaign: CampaignListItem;
summary: CampaignSummary | null;
failed: number;
outcomeUnknown: number;
notAttempted: number;
queuedOrActive: number;
imapFailed: number;
queueable: number;
needsAttention: number;
};
export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
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 [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState("");
const settingsKey = useMemo(
() => JSON.stringify({
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
useEffect(() => {
campaignsRef.current = [];
summariesRef.current = {};
resetDeltaWatermark();
void load();
}, [settingsKey, resetDeltaWatermark]);
async function load() {
setLoading(true);
setError("");
try {
const campaigns = await loadCampaignsDelta();
const summaries = await loadCampaignSummariesDelta(campaigns);
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function runAction(row: OperatorRow, action: "retry" | "unattempted") {
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 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) }));
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function loadCampaignsDelta(): Promise<CampaignListItem[]> {
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 loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
const summaries = { ...summariesRef.current };
await Promise.all(campaigns.map(async (campaign) => {
const key = operatorCampaignSummaryKey(campaign.id);
let nextWatermark = getDeltaWatermark(key);
let summary = summaries[campaign.id] ?? null;
let hasMore = false;
try {
do {
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
includeCurrentVersion: false,
includeVersions: false,
includeSummary: true,
since: nextWatermark
});
if (response.full || response.summary) summary = response.summary;
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
summaries[campaign.id] = summary;
} catch {
summaries[campaign.id] = summary;
}
}));
summariesRef.current = summaries;
return summaries;
}
function operatorCampaignListKey(): string {
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
}
function operatorCampaignSummaryKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-summary", 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,
imapFailed: acc.imapFailed + row.imapFailed
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 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: "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: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 270,
sticky: "end",
render: (row) =>
<div className="button-row compact-actions">
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}>
<ExternalLink />
</Button>
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
</div>
}],
[busy, navigate]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
<QueuePressureGrid items={[
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
{ 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.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
} />
</section>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
<Card title="i18n:govoplan-campaign.campaign_queues.657785f4">
<DataGrid
id="operator-queue-campaigns"
rows={rows}
columns={columns}
getRowKey={(row) => row.campaign.id}
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
className="data-table compact-table" />
</Card>
</LoadingFrame>
</div>);
}
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
const cards = asRecord(summary?.cards);
return {
campaign,
summary,
failed: numberValue(cards.failed),
outcomeUnknown: numberValue(cards.outcome_unknown),
notAttempted: numberValue(cards.not_attempted),
queuedOrActive: numberValue(cards.queued_or_active),
imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable),
needsAttention: numberValue(cards.needs_attention)
};
}
function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
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 ?? "")
);
}
function QueuePressureGrid({ items }: {items: Array<{label: string;value: number;tone: "neutral" | "good" | "warning" | "danger" | "info";}>;}) {
return (
<div className="metric-grid queue-pressure-grid">
{items.map((item) =>
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
)}
</div>);
}