initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

View 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>
);
}