258 lines
12 KiB
TypeScript
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>);
|
|
|
|
}
|