chore: sync GovOPlaN module split state
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ApiSettings, CampaignListItem } from "../../types";
|
||||
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||
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";
|
||||
@@ -10,7 +9,7 @@ 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 } from "@govoplan/core-webui";
|
||||
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
|
||||
|
||||
type OperatorRow = {
|
||||
@@ -25,29 +24,40 @@ type OperatorRow = {
|
||||
needsAttention: number;
|
||||
};
|
||||
|
||||
export default function OperatorQueuePage({ settings }: { settings: ApiSettings }) {
|
||||
const navigate = useNavigate();
|
||||
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("");
|
||||
|
||||
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey]);
|
||||
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 listCampaigns(settings);
|
||||
const summaries = await Promise.all(campaigns.map(async (campaign) => {
|
||||
try {
|
||||
return await getCampaignSummary(settings, campaign.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
setRows(campaigns.map((campaign, index) => toRow(campaign, summaries[index] ?? null)));
|
||||
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 {
|
||||
@@ -61,11 +71,12 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
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 }) :
|
||||
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
||||
const result = asRecord(response.result ?? response);
|
||||
setMessage(`${row.campaign.name}: ${humanize(String(result.action ?? action))}, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
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));
|
||||
@@ -74,49 +85,109 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
imapFailed: acc.imapFailed + row.imapFailed
|
||||
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 });
|
||||
|
||||
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
|
||||
{ id: "campaign", header: "Campaign", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
|
||||
{ id: "status", header: "Status", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
|
||||
{ id: "attention", header: "Attention", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
|
||||
{ id: "failed", header: "Failed", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
|
||||
{ id: "unknown", header: "Unknown", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
|
||||
{ id: "unattempted", header: "Unattempted", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
|
||||
{ id: "queued", header: "Queued/active", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
|
||||
{ id: "updated", header: "Updated", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
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={`Open queue for ${row.campaign.name}`} title={`Open queue for ${row.campaign.name}`}>
|
||||
{ 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)}>Retry</Button>
|
||||
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</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]);
|
||||
|
||||
}],
|
||||
[busy, navigate]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Operator queue</PageTitle>
|
||||
<p>Queue state, retry candidates and reconciliation entry points across accessible campaigns.</p>
|
||||
<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}>Refresh</Button>
|
||||
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,30 +195,30 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
{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">Queue pressure</h2>
|
||||
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
|
||||
<QueuePressureGrid items={[
|
||||
{ label: "Failed", value: totals.failed, tone: "danger" },
|
||||
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" },
|
||||
{ label: "Unattempted", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" },
|
||||
]} />
|
||||
{ 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="Loading operator queue…">
|
||||
<Card title="Campaign queues">
|
||||
<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="No accessible campaigns."
|
||||
className="data-table compact-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
|
||||
className="data-table compact-table" />
|
||||
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
|
||||
@@ -161,7 +232,7 @@ function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): Ope
|
||||
queuedOrActive: numberValue(cards.queued_or_active),
|
||||
imapFailed: numberValue(cards.imap_failed),
|
||||
queueable: numberValue(cards.queueable),
|
||||
needsAttention: numberValue(cards.needs_attention),
|
||||
needsAttention: numberValue(cards.needs_attention)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,12 +240,18 @@ function numberValue(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
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>
|
||||
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>);
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user