initial commit after split
This commit is contained in:
178
webui/src/features/operator/OperatorQueuePage.tsx
Normal file
178
webui/src/features/operator/OperatorQueuePage.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ApiSettings, CampaignListItem } from "../../types";
|
||||
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
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 = useNavigate();
|
||||
const [rows, setRows] = useState<OperatorRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
|
||||
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
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)));
|
||||
} 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(`${row.campaign.name}: ${humanize(String(result.action ?? action))}, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
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: "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: 320,
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)}>Open queue</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [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>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void load()} disabled={loading}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="Queue pressure"><MetricGrid items={[
|
||||
["Failed", totals.failed],
|
||||
["Outcome unknown", totals.outcomeUnknown],
|
||||
["Unattempted", totals.notAttempted],
|
||||
["Queued/active", totals.queuedOrActive],
|
||||
["IMAP failed", totals.imapFailed],
|
||||
]} /></Card>
|
||||
</div>
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading operator queue…">
|
||||
<Card title="Campaign queues">
|
||||
<DataGrid
|
||||
id="operator-queue-campaigns"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.campaign.id}
|
||||
emptyText="No accessible campaigns."
|
||||
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 MetricGrid({ items }: { items: Array<[string, number]> }) {
|
||||
return (
|
||||
<div className="status-grid">
|
||||
{items.map(([label, value]) => (
|
||||
<div className="status-card" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user