feat: add the admin Seed panel page to manage the instance placement seed inboxes, run operator tests and read every workspace's results, plus the placement allowance and pacing settings on the Configuration page

This commit is contained in:
Matthew Meszaros
2026-09-25 20:56:11 -07:00
parent bbcf8725c3
commit d4467ce10e
12 changed files with 1526 additions and 0 deletions
+558
View File
@@ -0,0 +1,558 @@
// Inbox placement seed panel. The instance panel is the set of mailboxes every
// workspace's placement tests send to; this page is where the operator builds
// it and watches the tests that run against it. No polling: the realtime
// spine's placement group invalidates ["admin","placement"] on
// PLACEMENT_TEST_UPDATED.
import { useMemo, useState } from "react";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Play, Plus, Search, Trash2 } from "lucide-react";
import { PageHeader } from "@/components/layout/PageHeader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import { useConfirm } from "@/components/ConfirmDialog";
import { DataTable, type Column } from "@/components/data/DataTable";
import { useAdminPerm } from "@/hooks/useAdminPerm";
import { AdminPerm } from "@/lib/auth/permissions";
import { cn } from "@/lib/utils";
import {
listPlacementSeeds,
listPlacementTests,
searchPlacementSeedCandidates,
setPlacementSeed,
type AdminPlacementSeed,
type PlacementTestView,
} from "@/lib/api/client/admin/placement";
import { absolute, relative } from "@/app/dashboard/jobs/format";
import { StatusBadge } from "@/app/dashboard/placement/badges";
import { describeError, ORIGIN_LABEL, PANEL_LABEL, pct } from "@/app/dashboard/placement/format";
import { RunTestDialog } from "@/app/dashboard/placement/RunTestDialog";
import { TestDetailSheet } from "@/app/dashboard/placement/TestDetailSheet";
import { useDebounced } from "@/app/dashboard/placement/useDebounced";
// A provider family with fewer seeds than this gives a verdict one mailbox can swing.
const MIN_SEEDS_PER_FAMILY = 3;
const SEED_STATUS_TONE: Record<string, string> = {
active: "border-emerald-300 bg-emerald-50 text-emerald-700",
inactive: "border-amber-300 bg-amber-50 text-amber-700",
revoked: "border-red-300 bg-red-50 text-red-700",
};
export default function PlacementPage() {
const [params, setParams] = useSearchParams();
const openTest = params.get("test");
const [runOpen, setRunOpen] = useState(false);
const canManage = useAdminPerm(AdminPerm.ManageWarmupBans);
function setOpenTest(id: string | null) {
setParams(
(p) => {
if (id) p.set("test", id);
else p.delete("test");
return p;
},
{ replace: true },
);
}
return (
<div>
<PageHeader
title="Seed panel"
description="The mailboxes every workspace's inbox placement tests send to on this instance, and every test run against a panel."
>
{canManage && (
<Button size="sm" onClick={() => setRunOpen(true)}>
<Play className="size-4" /> Run a test
</Button>
)}
</PageHeader>
<SeedsSection canManage={canManage} />
{canManage && <AddSeedsSection />}
<TestsSection onOpen={setOpenTest} />
<TestDetailSheet
testId={openTest}
onOpenChange={(open) => !open && setOpenTest(null)}
onOpenTest={setOpenTest}
/>
{canManage && (
<RunTestDialog
open={runOpen}
onOpenChange={setRunOpen}
onStarted={(tests) => {
setRunOpen(false);
if (tests[0]) setOpenTest(tests[0].id);
}}
/>
)}
</div>
);
}
function SectionTitle({ title, hint }: { title: string; hint?: string }) {
return (
<div className="mb-2">
<h2 className="text-sm font-semibold">{title}</h2>
{hint && <p className="mt-0.5 max-w-3xl text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
function SeedsSection({ canManage }: { canManage: boolean }) {
const qc = useQueryClient();
const confirm = useConfirm();
const canViewOrgs = useAdminPerm(AdminPerm.ViewOrganizations);
const canViewWorkers = useAdminPerm(AdminPerm.ViewWorkers);
const seedsQ = useQuery({
queryKey: ["admin", "placement", "seeds"],
queryFn: listPlacementSeeds,
});
const seeds = useMemo(() => seedsQ.data ?? [], [seedsQ.data]);
const mix = useMemo(() => {
const counts = new Map<string, number>();
for (const s of seeds) {
const label = s.family_label || "Other provider";
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
}, [seeds]);
const remove = useMutation({
mutationFn: (seed: AdminPlacementSeed) => setPlacementSeed(seed.id, false),
onSuccess: (seed) => {
toast.success(`${seed.email} removed from the panel`);
qc.invalidateQueries({ queryKey: ["admin", "placement"] });
},
onError: (err) => toast.error(describeError(err).message || "Could not remove the seed"),
});
async function onRemove(seed: AdminPlacementSeed) {
const ok = await confirm({
title: "Remove this seed?",
description: `${seed.email} stops receiving copies from new placement tests. The mailbox stays connected, and its warmup stays off until someone turns it back on.`,
confirmLabel: "Remove seed",
destructive: true,
});
if (!ok) return;
remove.mutate(seed);
}
const columns: Column<AdminPlacementSeed>[] = [
{
id: "mailbox",
header: "Mailbox",
cell: (s) => (
<div className="min-w-0">
<div className="font-mono text-xs">{s.email}</div>
{s.name && <div className="text-[11px] text-muted-foreground">{s.name}</div>}
</div>
),
csv: (s) => s.email,
},
{
id: "family",
header: "Provider",
cell: (s) => <span className="text-xs">{s.family_label}</span>,
csv: (s) => s.family_label,
},
{
id: "status",
header: "Status",
cell: (s) => (
<Badge
variant="outline"
className={cn("text-[10px]", SEED_STATUS_TONE[s.status] ?? "border-zinc-300 text-zinc-600")}
>
{s.status}
</Badge>
),
csv: (s) => s.status,
},
{
id: "worker",
header: "Worker",
cell: (s) =>
s.worker_id ? (
canViewWorkers ? (
<Link
to={`/workers/${s.worker_id}`}
className="font-mono text-[11px] text-[var(--admin-accent-strong)] hover:underline"
>
{s.worker_id.slice(0, 8)}
</Link>
) : (
<span className="font-mono text-[11px]">{s.worker_id.slice(0, 8)}</span>
)
) : (
<Badge
variant="outline"
className="border-amber-300 bg-amber-50 text-[10px] text-amber-700"
title="Nothing syncs this mailbox, so copies sent to it read as missing"
>
unassigned
</Badge>
),
csv: (s) => s.worker_id ?? "",
},
{
id: "organization",
header: "Organization",
cell: (s) =>
s.organization_id ? (
canViewOrgs ? (
<Link
to={`/organizations/${s.organization_id}`}
className="font-mono text-[11px] text-[var(--admin-accent-strong)] hover:underline"
>
{s.organization_id.slice(0, 8)}
</Link>
) : (
<span className="font-mono text-[11px]">{s.organization_id.slice(0, 8)}</span>
)
) : (
<span className="text-xs text-muted-foreground">—</span>
),
csv: (s) => s.organization_id ?? "",
},
];
if (canManage) {
columns.push({
id: "actions",
header: "",
align: "right",
cell: (s) => (
<Button
size="xs"
variant="outline"
disabled={remove.isPending && remove.variables?.id === s.id}
onClick={(e) => {
e.stopPropagation();
void onRemove(s);
}}
>
<Trash2 /> Remove
</Button>
),
});
}
return (
<section>
<SectionTitle
title="Seeds"
hint="Copies are spread round-robin across provider families, so every family on the panel is covered by each test. Seeds on the sender's own domain are always skipped."
/>
<div className="mb-3 rounded-lg border border-border bg-card p-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Mix by provider
</div>
{seedsQ.isLoading ? (
<Skeleton className="h-6 w-2/3" />
) : mix.length === 0 ? (
<p className="text-xs text-muted-foreground">The panel is empty, so no workspace can test on it yet.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{mix.map(([label, n]) => (
<span
key={label}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs",
n < MIN_SEEDS_PER_FAMILY
? "border-amber-300 bg-amber-50 text-amber-800"
: "border-border bg-muted/30",
)}
title={n < MIN_SEEDS_PER_FAMILY ? `Fewer than ${MIN_SEEDS_PER_FAMILY} seeds` : undefined}
>
{label}
<span className="font-semibold tabular-nums">{n}</span>
</span>
))}
</div>
)}
<p className="mt-2 max-w-3xl text-xs text-muted-foreground">
A healthy panel is weighted toward Microsoft 365 and Google Workspace, where most B2B recipients
are, with a few Gmail, Outlook.com and Yahoo seeds, and at least {MIN_SEEDS_PER_FAMILY} per
provider. Seeds must be mailboxes nobody reads or engages with: opening, filing or replying to a
copy teaches the provider to trust the sender and skews every later result.
</p>
</div>
<DataTable
columns={columns}
rows={seeds}
getRowId={(s) => s.id}
loading={seedsQ.isLoading}
error={seedsQ.error}
onRetry={() => seedsQ.refetch()}
errorTitle="Failed to load the seed panel"
storageKey="admin.placement.seeds"
csvName="warmbly-placement-seeds"
noun="seeds"
emptyTitle="No seeds yet"
emptyHint={
canManage
? "Add connected mailboxes below to build the panel."
: "An admin with the manage warmup bans permission can add them."
}
/>
</section>
);
}
function AddSeedsSection() {
const qc = useQueryClient();
const confirm = useConfirm();
const [search, setSearch] = useState("");
const debounced = useDebounced(search.trim(), 250);
const candidatesQ = useQuery({
queryKey: ["admin", "placement", "candidates", debounced],
queryFn: () => searchPlacementSeedCandidates(debounced),
enabled: debounced.length >= 2,
staleTime: 30_000,
});
const rows = candidatesQ.data ?? [];
const add = useMutation({
mutationFn: (seed: AdminPlacementSeed) => setPlacementSeed(seed.id, true),
onSuccess: (seed) => {
toast.success(`${seed.email} added to the panel, with its warmup turned off`);
qc.invalidateQueries({ queryKey: ["admin", "placement"] });
},
onError: (err) => toast.error(describeError(err).message || "Could not add the seed"),
});
async function onAdd(seed: AdminPlacementSeed) {
if (seed.seed_scope === "workspace") {
const ok = await confirm({
title: "Move this workspace seed?",
description: `${seed.email} is one of its workspace's own test inboxes. Adding it here moves it to the instance panel, so its workspace can no longer test on it privately.`,
confirmLabel: "Add to the panel",
});
if (!ok) return;
}
add.mutate(seed);
}
return (
<section className="mt-6">
<SectionTitle
title="Add seeds"
hint="Any connected mailbox can join the panel. Adding one turns its warmup off and keeps it out of campaign sending, so pick mailboxes that exist only to receive tests."
/>
<div className="relative mb-2 max-w-md">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search connected mailboxes by address…"
className="h-8 pl-8 text-[12.5px]"
autoComplete="off"
/>
</div>
{debounced.length < 2 ? null : candidatesQ.isLoading ? (
<Skeleton className="h-24 w-full" />
) : candidatesQ.error ? (
<ErrorState error={candidatesQ.error} title="Search failed" onRetry={() => candidatesQ.refetch()} />
) : rows.length === 0 ? (
<div className="rounded-md border border-border bg-card p-4 text-sm text-muted-foreground">
No connected mailbox matches.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left">Mailbox</th>
<th className="px-3 py-2 text-left">Provider</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{rows.map((m) => {
const onPanel = m.seed_scope === "instance";
return (
<tr key={m.id} className="border-t border-border">
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs">{m.email}</span>
{m.seed_scope === "workspace" && (
<Badge
variant="outline"
className="border-sky-300 bg-sky-50 text-[10px] text-sky-700"
>
workspace seed
</Badge>
)}
</div>
</td>
<td className="px-3 py-2 text-xs">{m.family_label}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
className={cn(
"text-[10px]",
SEED_STATUS_TONE[m.status] ?? "border-zinc-300 text-zinc-600",
)}
>
{m.status}
</Badge>
</td>
<td className="px-3 py-2 text-right">
<Button
size="xs"
variant="outline"
disabled={onPanel || (add.isPending && add.variables?.id === m.id)}
onClick={() => void onAdd(m)}
>
<Plus /> {onPanel ? "On the panel" : "Add"}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</section>
);
}
function TestsSection({ onOpen }: { onOpen: (id: string) => void }) {
const testsQ = useInfiniteQuery({
queryKey: ["admin", "placement", "tests"],
queryFn: ({ pageParam }) => listPlacementTests(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (last) =>
last.pagination?.has_more ? (last.pagination.next_cursor ?? undefined) : undefined,
});
const rows = useMemo(() => (testsQ.data?.pages ?? []).flatMap((p) => p.data ?? []), [testsQ.data]);
const total = testsQ.data?.pages[0]?.pagination?.total;
const columns: Column<PlacementTestView>[] = [
{
id: "created",
header: "Created",
cell: (t) => (
<span className="whitespace-nowrap text-xs text-muted-foreground" title={absolute(t.created_at)}>
{relative(t.created_at)}
</span>
),
csv: (t) => t.created_at,
},
{
id: "sender",
header: "Sender",
cell: (t) => <span className="font-mono text-xs">{t.sender_email || "—"}</span>,
csv: (t) => t.sender_email,
},
{
id: "subject",
header: "Subject",
cell: (t) => <span className="block max-w-72 truncate text-xs">{t.subject || "—"}</span>,
csv: (t) => t.subject,
},
{
id: "panel",
header: "Panel",
cell: (t) => <span className="text-xs">{PANEL_LABEL[t.panel] ?? t.panel}</span>,
csv: (t) => t.panel,
},
{
id: "origin",
header: "Origin",
cell: (t) => <span className="text-xs">{ORIGIN_LABEL[t.origin] ?? t.origin}</span>,
csv: (t) => t.origin,
},
{
id: "status",
header: "Status",
cell: (t) => <StatusBadge status={t.status} />,
csv: (t) => t.status,
},
{
id: "inbox",
header: "Inbox",
align: "right",
cell: (t) => <span className="text-xs font-medium tabular-nums">{pct(t.summary?.inbox_rate)}</span>,
csv: (t) => pct(t.summary?.inbox_rate),
},
{
id: "spam",
header: "Spam",
align: "right",
cell: (t) => (
<span className={cn("text-xs tabular-nums", (t.summary?.spam ?? 0) > 0 && "text-red-700")}>
{pct(t.summary?.spam_rate)}
</span>
),
csv: (t) => pct(t.summary?.spam_rate),
},
{
id: "delivered",
header: "Delivered",
align: "right",
cell: (t) => (
<span className="text-xs tabular-nums">
{t.summary?.delivered ?? 0}
<span className="text-muted-foreground"> / {t.summary?.total ?? 0}</span>
</span>
),
csv: (t) => t.summary?.delivered ?? 0,
},
];
return (
<section className="mt-6">
<SectionTitle
title="Recent tests"
hint={`Every placement test on this instance, from every workspace, newest first.${
total !== undefined ? ` ${total.toLocaleString()} in total.` : ""
}`}
/>
<DataTable
columns={columns}
rows={rows}
getRowId={(t) => t.id}
loading={testsQ.isLoading}
error={testsQ.error}
onRetry={() => testsQ.refetch()}
onRowClick={(t) => onOpen(t.id)}
errorTitle="Failed to load placement tests"
storageKey="admin.placement.tests"
csvName="warmbly-placement-tests"
noun="tests"
emptyTitle="No placement tests yet"
emptyHint="Tests appear here once a workspace, a campaign monitor or an admin runs one."
/>
{testsQ.hasNextPage && (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="outline"
onClick={() => testsQ.fetchNextPage()}
disabled={testsQ.isFetchingNextPage}
>
{testsQ.isFetchingNextPage ? "Loading…" : "Load more"}
</Button>
</div>
)}
</section>
);
}
@@ -188,6 +188,52 @@ const TRACKING_FIELDS = [
type TrackingFieldKey = (typeof TRACKING_FIELDS)[number]["key"];
// Inbox placement test allowance and pacing, mirroring internal/config/constants.go.
const PLACEMENT_FIELDS = [
{
key: "testsTrial",
setting: "tests_per_month_trial",
label: "Tests per month on trial",
min: 1,
max: 100000,
help: "How many tests a workspace without a paid plan may run on the metered panels each calendar month.",
},
{
key: "testsPaid",
setting: "tests_per_month_paid",
label: "Tests per month on paid plans",
min: 1,
max: 100000,
help: "The same allowance for a workspace with an active subscription.",
},
{
key: "seedsPerTest",
setting: "seeds_per_test",
label: "Seeds per test",
min: 1,
max: 100,
help: "The most seeds one test sends to, which is also how many sends it takes from the sending mailbox's daily limit.",
},
{
key: "spacingSeconds",
setting: "spacing_seconds",
label: "Spacing between copies (seconds)",
min: 5,
max: 600,
help: "The gap between two copies from one mailbox, jittered, so a test never leaves as a burst.",
},
] as const;
type PlacementFieldKey = (typeof PLACEMENT_FIELDS)[number]["key"];
// A backend from before the placement section omits it; the form shows the compiled defaults.
const PLACEMENT_DEFAULTS: InstanceSettings["placement"] = {
tests_per_month_trial: 3,
tests_per_month_paid: 40,
seeds_per_test: 40,
spacing_seconds: 20,
};
interface FormState {
linksEnabled: boolean;
ttlHours: string;
@@ -195,11 +241,13 @@ interface FormState {
sync: Record<SyncFieldKey, string>;
retention: Record<RetentionFieldKey, string>;
tracking: Record<TrackingFieldKey, string>;
placement: Record<PlacementFieldKey, string>;
enforceDomainAuth: boolean;
authGraceHours: string;
}
function toForm(s: InstanceSettings): FormState {
const placement = s.placement ?? PLACEMENT_DEFAULTS;
return {
linksEnabled: s.invitations.links_enabled,
ttlHours: String(s.invitations.ttl_hours),
@@ -222,6 +270,12 @@ function toForm(s: InstanceSettings): FormState {
machineWindowClick: String(s.tracking.machine_window_click_seconds),
machineWindowProbable: String(s.tracking.machine_window_probable_seconds),
},
placement: {
testsTrial: String(placement.tests_per_month_trial),
testsPaid: String(placement.tests_per_month_paid),
seedsPerTest: String(placement.seeds_per_test),
spacingSeconds: String(placement.spacing_seconds),
},
enforceDomainAuth: s.deliverability.enforce_domain_auth,
authGraceHours: String(s.deliverability.auth_grace_hours),
};
@@ -280,6 +334,13 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
!!server &&
!!form &&
TRACKING_FIELDS.some((f) => form.tracking[f.key] !== String(server.tracking[f.setting]));
const placementDirty =
!!server &&
!!form &&
PLACEMENT_FIELDS.some(
(f) =>
form.placement[f.key] !== String((server.placement ?? PLACEMENT_DEFAULTS)[f.setting]),
);
const dirty =
!!server &&
!!form &&
@@ -290,6 +351,7 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
form.authGraceHours !== String(server.deliverability.auth_grace_hours) ||
retentionDirty ||
trackingDirty ||
placementDirty ||
syncDirty);
useEffect(() => {
@@ -306,6 +368,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
form !== null &&
TRACKING_FIELDS.every((f) => syncFieldValid(form.tracking[f.key], f.min, f.max));
const placementValid =
form !== null &&
PLACEMENT_FIELDS.every((f) => syncFieldValid(form.placement[f.key], f.min, f.max));
const authGrace = form ? Number(form.authGraceHours) : NaN;
const authGraceValid =
form !== null &&
@@ -346,6 +412,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
);
return;
}
if (!placementValid) {
toast.error("Every placement test setting must be a whole number inside the range shown under it");
return;
}
if (!authGraceValid) {
toast.error(
`The authentication grace period must be a whole number of hours between ${AUTH_GRACE_MIN_HOURS} and ${AUTH_GRACE_MAX_HOURS}`,
@@ -377,6 +447,12 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
enforce_domain_auth: form.enforceDomainAuth,
auth_grace_hours: authGrace,
},
placement: {
tests_per_month_trial: Number(form.placement.testsTrial),
tests_per_month_paid: Number(form.placement.testsPaid),
seeds_per_test: Number(form.placement.seedsPerTest),
spacing_seconds: Number(form.placement.spacingSeconds),
},
});
}
@@ -701,6 +777,59 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Inbox placement tests</CardTitle>
<CardDescription>
A placement test sends one copy of a template to each seed on a
panel and reports where it landed. The monthly allowances count
tests on the instance panel and on Warmbly Cloud&apos;s; tests on a
workspace&apos;s own seed inboxes are never counted, and a
self-hosted instance does not meter tests at all. A tracking
comparison counts as two tests. The seeds themselves are managed on
the Seed panel page.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-3 pt-0 md:grid-cols-2">
{PLACEMENT_FIELDS.map((f) => {
const valid = syncFieldValid(form.placement[f.key], f.min, f.max);
return (
<div key={f.key}>
<Label htmlFor={`placement-${f.key}`}>{f.label}</Label>
<Input
id={`placement-${f.key}`}
type="text"
inputMode="numeric"
autoComplete="off"
value={form.placement[f.key]}
onChange={(e) =>
setForm({
...form,
placement: {
...form.placement,
[f.key]: e.target.value,
},
})
}
aria-invalid={!valid}
className="mt-1"
/>
<p className="mt-1 text-xs text-muted-foreground">
{f.help} Between {f.min.toLocaleString()} and{" "}
{f.max.toLocaleString()}.
</p>
{!valid && (
<p className="mt-1 text-xs text-red-600">
Enter a whole number between {f.min.toLocaleString()}{" "}
and {f.max.toLocaleString()}.
</p>
)}
</div>
);
})}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Sending-domain authentication</CardTitle>
@@ -0,0 +1,269 @@
// Runs a placement test on the instance panel from any connected mailbox,
// outside every workspace allowance.
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
createPlacementTest,
searchPlacementSeedCandidates,
type AdminPlacementSeed,
type PlacementTestView,
type PlacementTracking,
} from "@/lib/api/client/admin/placement";
import { describeError } from "./format";
import { useDebounced } from "./useDebounced";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TRACKING_OPTIONS: { value: PlacementTracking; label: string }[] = [
{ value: "campaign", label: "Default: untracked for an ad-hoc test" },
{ value: "off", label: "Off: no open pixel, links left as written" },
{ value: "on", label: "On: open pixel and click tracking" },
{ value: "compare", label: "Compare: one untracked and one tracked test" },
];
export function RunTestDialog({
open,
onOpenChange,
onStarted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onStarted: (tests: PlacementTestView[]) => void;
}) {
const qc = useQueryClient();
const [sender, setSender] = useState<{ id: string; email: string } | null>(null);
const [senderQuery, setSenderQuery] = useState("");
const [subject, setSubject] = useState("");
const [bodyPlain, setBodyPlain] = useState("");
const [bodyHtml, setBodyHtml] = useState("");
const [tracking, setTracking] = useState<PlacementTracking>("campaign");
const [failure, setFailure] = useState<{ message: string; code?: string } | null>(null);
function reset() {
setSender(null);
setSenderQuery("");
setSubject("");
setBodyPlain("");
setBodyHtml("");
setTracking("campaign");
setFailure(null);
}
const debounced = useDebounced(senderQuery.trim(), 250);
const isId = UUID_RE.test(debounced);
const candidatesQ = useQuery({
queryKey: ["admin", "placement", "candidates", "sender", debounced],
queryFn: () => searchPlacementSeedCandidates(debounced, 20),
enabled: open && !sender && debounced.length >= 2 && !isId,
staleTime: 30_000,
});
// A seed cannot send a test, and an inactive mailbox cannot send at all.
const senders = (candidatesQ.data ?? []).filter((m) => m.seed_scope === "" && m.status === "active");
const senderId = sender?.id ?? (UUID_RE.test(senderQuery.trim()) ? senderQuery.trim() : "");
const create = useMutation({
mutationFn: () =>
createPlacementTest({
sender_account_id: senderId,
subject: subject.trim(),
body_plain: bodyPlain.trim() || undefined,
body_html: bodyHtml.trim() || undefined,
tracking,
}),
onSuccess: (tests) => {
toast.success(tests.length > 1 ? "Tracking comparison started" : "Placement test started");
qc.invalidateQueries({ queryKey: ["admin", "placement"] });
reset();
onStarted(tests);
},
onError: (err) => setFailure(describeError(err)),
});
const problem = !senderId
? "Pick the mailbox to send from, or paste its id."
: !subject.trim()
? "Enter a subject."
: !bodyPlain.trim() && !bodyHtml.trim()
? "Enter a plain or an HTML body."
: null;
function pick(m: AdminPlacementSeed) {
setSender({ id: m.id, email: m.email });
setSenderQuery("");
setFailure(null);
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v && !create.isPending) setFailure(null);
onOpenChange(v);
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Run a placement test</DialogTitle>
<DialogDescription>
Sends one copy to each seed on the instance panel from the mailbox you pick, spaced out, and
reports where each landed. Admin tests are never counted against the workspace&apos;s monthly
allowance, but every copy uses the mailbox&apos;s daily sending limit.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs">Sending mailbox</Label>
{sender ? (
<div className="mt-1 flex h-8 items-center justify-between gap-2 rounded-md border border-border px-2.5 text-[12.5px]">
<span className="truncate font-mono">{sender.email}</span>
<Button
size="icon-xs"
variant="ghost"
title="Change mailbox"
onClick={() => setSender(null)}
>
<X />
</Button>
</div>
) : (
<>
<Input
value={senderQuery}
onChange={(e) => {
setSenderQuery(e.target.value);
setFailure(null);
}}
placeholder="Search by address, or paste a mailbox id"
className="mt-1 h-8 text-[12.5px]"
autoComplete="off"
/>
{debounced.length >= 2 && !isId && (
<div className="mt-1 max-h-44 overflow-y-auto rounded-md border border-border">
{candidatesQ.isLoading ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">Searching…</div>
) : senders.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No active mailbox matches. Seeds cannot send a test.
</div>
) : (
senders.map((m) => (
<button
key={m.id}
type="button"
onClick={() => pick(m)}
className="flex w-full items-center justify-between gap-2 border-b border-border px-2.5 py-1.5 text-left text-[12.5px] last:border-b-0 hover:bg-muted/50"
>
<span className="truncate font-mono">{m.email}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{m.family_label}
</span>
</button>
))
)}
</div>
)}
</>
)}
</div>
<div>
<Label htmlFor="placement-subject" className="text-xs">
Subject
</Label>
<Input
id="placement-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="mt-1 h-8 text-[12.5px]"
/>
</div>
<div>
<Label htmlFor="placement-plain" className="text-xs">
Plain body
</Label>
<Textarea
id="placement-plain"
value={bodyPlain}
onChange={(e) => setBodyPlain(e.target.value)}
rows={4}
className="mt-1 text-[12.5px]"
/>
</div>
<div>
<Label htmlFor="placement-html" className="text-xs">
HTML body
</Label>
<Textarea
id="placement-html"
value={bodyHtml}
onChange={(e) => setBodyHtml(e.target.value)}
rows={4}
placeholder="Optional. Leave empty to send plain text only."
className="mt-1 font-mono text-[12px]"
/>
</div>
<div>
<Label className="text-xs">Tracking</Label>
<Select value={tracking} onValueChange={(v) => setTracking(v as PlacementTracking)}>
<SelectTrigger className="mt-1 h-8 w-full text-[12.5px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRACKING_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[12.5px]">
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{tracking === "compare" && (
<p className="mt-1 text-xs text-muted-foreground">
Two tests to the same seeds, so twice the sends from the mailbox.
</p>
)}
</div>
{failure && (
<div className="rounded-md border border-red-200 bg-red-50 p-2.5 text-xs text-red-700">
{failure.message}
{failure.code && <span className="ml-1.5 font-mono text-red-600/80">({failure.code})</span>}
</div>
)}
</div>
<DialogFooter className="items-center sm:justify-between">
<span className="text-xs text-muted-foreground">{problem ?? ""}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={create.isPending}>
Cancel
</Button>
<Button onClick={() => create.mutate()} disabled={!!problem || create.isPending}>
{create.isPending ? "Starting…" : "Start test"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,279 @@
// One placement test in full: where each copy landed, per host family and per
// seed, and the rules pass over the copy. Kept live by the realtime spine's
// placement group.
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight } from "lucide-react";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ErrorState";
import { cn } from "@/lib/utils";
import { getPlacementTest, type PlacementCounts } from "@/lib/api/client/admin/placement";
import { absolute, relative } from "@/app/dashboard/jobs/format";
import { FolderBadge, StatusBadge } from "./badges";
import { ORIGIN_LABEL, PANEL_LABEL, pct } from "./format";
export function TestDetailSheet({
testId,
onOpenChange,
onOpenTest,
}: {
testId: string | null;
onOpenChange: (open: boolean) => void;
onOpenTest: (id: string) => void;
}) {
return (
<Sheet open={testId !== null} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full gap-0 sm:max-w-2xl">
{testId && <Detail id={testId} onOpenTest={onOpenTest} />}
</SheetContent>
</Sheet>
);
}
function Detail({ id, onOpenTest }: { id: string; onOpenTest: (id: string) => void }) {
const q = useQuery({
queryKey: ["admin", "placement", "test", id],
queryFn: () => getPlacementTest(id),
});
const t = q.data;
return (
<>
<SheetHeader className="border-b border-border pr-10">
<SheetTitle className="truncate">{t?.subject || "Placement test"}</SheetTitle>
<SheetDescription className="text-xs">
{t ? (
<>
From <span className="font-mono">{t.sender_email || "unknown sender"}</span>, started{" "}
<span title={absolute(t.created_at)}>{relative(t.created_at)}</span>
</>
) : (
<span className="font-mono">{id}</span>
)}
</SheetDescription>
</SheetHeader>
<div className="flex-1 space-y-5 overflow-y-auto p-4">
{q.isLoading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-40 w-full" />
</div>
)}
{q.error && <ErrorState error={q.error} title="Failed to load the test" onRetry={() => q.refetch()} />}
{t && (
<>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<StatusBadge status={t.status} />
<Badge variant="outline" className="text-[10px]">
{PANEL_LABEL[t.panel] ?? t.panel} panel
</Badge>
<Badge variant="outline" className="text-[10px]">
{ORIGIN_LABEL[t.origin] ?? t.origin}
</Badge>
<Badge variant="outline" className="text-[10px]">
{t.open_tracking || t.link_tracking
? `tracked (${[t.open_tracking && "opens", t.link_tracking && "clicks"].filter(Boolean).join(", ")})`
: "untracked"}
</Badge>
{t.finished_at && (
<span className="text-muted-foreground" title={absolute(t.finished_at)}>
finished {relative(t.finished_at)}
</span>
)}
</div>
{t.error && (
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-xs text-red-700">{t.error}</div>
)}
{t.compare && (
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 p-3 text-xs">
<span className="text-muted-foreground">
Half of a tracking comparison. The other half was sent{" "}
{t.compare.open_tracking || t.compare.link_tracking ? "tracked" : "untracked"} to the same
seeds and reached the primary inbox at{" "}
<span className="font-medium text-foreground">{pct(t.compare.summary.inbox_rate)}</span>.
</span>
<Button size="xs" variant="outline" onClick={() => onOpenTest(t.compare!.id)}>
<ArrowLeftRight /> Open
</Button>
</div>
)}
<SummaryGrid counts={t.summary} />
<Section title="By provider">
<FamiliesTable families={t.families ?? []} />
</Section>
<Section title={`Content score ${t.content.score}/100`}>
{(t.content.issues ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">The rules pass found nothing to flag in this copy.</p>
) : (
<ul className="space-y-1.5">
{(t.content.issues ?? []).map((issue, i) => (
<li key={`${issue.code}-${i}`} className="rounded-md border border-border p-2 text-xs">
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className={cn(
"text-[10px]",
issue.severity === "high"
? "border-red-300 bg-red-50 text-red-700"
: "border-amber-300 bg-amber-50 text-amber-700",
)}
>
{issue.severity}
</Badge>
<span>{issue.message}</span>
</div>
{issue.suggestion && (
<p className="mt-1 text-muted-foreground">{issue.suggestion}</p>
)}
</li>
))}
</ul>
)}
</Section>
<Section title="Copies">
<ResultsTable results={t.results ?? []} />
</Section>
<p className="text-[11px] text-muted-foreground">
{t.campaign_id ? "A campaign step" : "An ad-hoc template"}. Test id{" "}
<span className="font-mono select-text">{t.id}</span>
</p>
</>
)}
</div>
</>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{title}</h3>
{children}
</section>
);
}
function SummaryGrid({ counts }: { counts: PlacementCounts }) {
const tabs = counts.promotions + counts.other;
const cells: { label: string; value: number; sub?: string; tone?: string }[] = [
{ label: "Delivered", value: counts.delivered, sub: `of ${counts.total}` },
{ label: "Primary inbox", value: counts.inbox, sub: pct(counts.inbox_rate), tone: "text-emerald-700" },
{ label: "Gmail tabs", value: tabs, sub: pct(counts.tabs_rate), tone: "text-sky-700" },
{ label: "Spam", value: counts.spam, sub: pct(counts.spam_rate), tone: counts.spam > 0 ? "text-red-700" : undefined },
{
label: "Missing",
value: counts.missing,
sub: pct(counts.missing_rate),
tone: counts.missing > 0 ? "text-amber-700" : undefined,
},
{
label: "Pending",
value: counts.pending,
sub: counts.failed || counts.cancelled ? `${counts.failed} failed, ${counts.cancelled} cancelled` : undefined,
},
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{cells.map((c) => (
<div key={c.label} className="rounded-md border border-border bg-card p-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{c.label}</div>
<div className={cn("mt-0.5 text-lg font-semibold tabular-nums", c.tone)}>{c.value.toLocaleString()}</div>
{c.sub && <div className="text-[11px] text-muted-foreground tabular-nums">{c.sub}</div>}
</div>
))}
</div>
);
}
function FamiliesTable({ families }: { families: { family: string; label: string; counts: PlacementCounts }[] }) {
if (families.length === 0) {
return <p className="text-xs text-muted-foreground">No copies yet.</p>;
}
return (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-2.5 py-1.5 text-left font-medium">Provider</th>
<th className="px-2.5 py-1.5 text-right font-medium">Delivered</th>
<th className="px-2.5 py-1.5 text-right font-medium">Inbox</th>
<th className="px-2.5 py-1.5 text-right font-medium">Tabs</th>
<th className="px-2.5 py-1.5 text-right font-medium">Spam</th>
<th className="px-2.5 py-1.5 text-right font-medium">Missing</th>
<th className="px-2.5 py-1.5 text-right font-medium">Inbox rate</th>
</tr>
</thead>
<tbody>
{families.map((f) => (
<tr key={f.family} className="border-t border-border tabular-nums">
<td className="px-2.5 py-1.5">{f.label}</td>
<td className="px-2.5 py-1.5 text-right">{f.counts.delivered}</td>
<td className="px-2.5 py-1.5 text-right">{f.counts.inbox}</td>
<td className="px-2.5 py-1.5 text-right">{f.counts.promotions + f.counts.other}</td>
<td className={cn("px-2.5 py-1.5 text-right", f.counts.spam > 0 && "text-red-700")}>{f.counts.spam}</td>
<td className={cn("px-2.5 py-1.5 text-right", f.counts.missing > 0 && "text-amber-700")}>
{f.counts.missing}
</td>
<td className="px-2.5 py-1.5 text-right font-medium">{pct(f.counts.inbox_rate)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function ResultsTable({
results,
}: {
results: { seed: string; family_label: string; folder: string; sent_at: string | null; detected_at: string | null; error?: string }[];
}) {
if (results.length === 0) {
return <p className="text-xs text-muted-foreground">No copies were scheduled.</p>;
}
return (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-xs">
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-2.5 py-1.5 text-left font-medium">Seed</th>
<th className="px-2.5 py-1.5 text-left font-medium">Provider</th>
<th className="px-2.5 py-1.5 text-left font-medium">Folder</th>
<th className="px-2.5 py-1.5 text-left font-medium">Sent</th>
<th className="px-2.5 py-1.5 text-left font-medium">Found</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={`${r.seed}-${i}`} className="border-t border-border align-top">
<td className="px-2.5 py-1.5 font-mono">{r.seed}</td>
<td className="px-2.5 py-1.5">{r.family_label}</td>
<td className="px-2.5 py-1.5">
<FolderBadge folder={r.folder} />
{r.error && <div className="mt-0.5 max-w-56 text-[11px] text-red-700">{r.error}</div>}
</td>
<td className="px-2.5 py-1.5 whitespace-nowrap text-muted-foreground" title={absolute(r.sent_at)}>
{r.sent_at ? relative(r.sent_at) : "—"}
</td>
<td className="px-2.5 py-1.5 whitespace-nowrap text-muted-foreground" title={absolute(r.detected_at)}>
{r.detected_at ? relative(r.detected_at) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,54 @@
// Status and folder badges for placement tests.
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { PlacementFolder, PlacementStatus } from "@/lib/api/client/admin/placement";
const STATUS_TONE: Record<PlacementStatus, string> = {
running: "border-amber-300 bg-amber-50 text-amber-700",
completed: "border-emerald-300 bg-emerald-50 text-emerald-700",
cancelled: "border-zinc-300 bg-zinc-50 text-zinc-600",
failed: "border-red-300 bg-red-50 text-red-700",
};
export function StatusBadge({ status }: { status: string }) {
return (
<Badge
variant="outline"
className={cn("text-[10px]", STATUS_TONE[status as PlacementStatus] ?? "border-zinc-300 text-zinc-600")}
>
{status}
</Badge>
);
}
const FOLDER_LABEL: Record<PlacementFolder, string> = {
pending: "Pending",
inbox: "Primary inbox",
promotions: "Promotions",
other: "Other tab",
spam: "Spam",
missing: "Missing",
failed: "Failed",
cancelled: "Cancelled",
};
const FOLDER_TONE: Record<PlacementFolder, string> = {
pending: "border-zinc-300 bg-zinc-50 text-zinc-600",
inbox: "border-emerald-300 bg-emerald-50 text-emerald-700",
promotions: "border-sky-300 bg-sky-50 text-sky-700",
other: "border-sky-300 bg-sky-50 text-sky-700",
spam: "border-red-300 bg-red-50 text-red-700",
missing: "border-amber-300 bg-amber-50 text-amber-700",
failed: "border-red-300 text-red-700",
cancelled: "border-zinc-300 text-zinc-500",
};
export function FolderBadge({ folder }: { folder: string }) {
const f = folder as PlacementFolder;
return (
<Badge variant="outline" className={cn("text-[10px]", FOLDER_TONE[f] ?? "border-zinc-300 text-zinc-600")}>
{FOLDER_LABEL[f] ?? folder}
</Badge>
);
}
@@ -0,0 +1,31 @@
// Shared labels and helpers for the seed panel page and its test drawer.
import { APIError } from "@/lib/api/client";
export function pct(rate: number | null | undefined): string {
if (rate === null || rate === undefined || !Number.isFinite(rate)) return "—";
return `${Math.round(rate * 100)}%`;
}
export const PANEL_LABEL: Record<string, string> = {
instance: "Instance",
workspace: "Workspace",
cloud: "Warmbly Cloud",
};
export const ORIGIN_LABEL: Record<string, string> = {
manual: "Workspace member",
monitor: "Monitor",
admin: "Admin",
remote: "Linked instance",
};
// The backend's `error` field is the HTTP status text; the sentence worth
// showing is `message`.
export function describeError(err: unknown): { message: string; code?: string } {
if (err instanceof APIError) {
const body = err.body as { message?: string } | undefined;
return { message: body?.message || err.message, code: err.code };
}
return { message: err instanceof Error ? err.message : "Request failed" };
}
@@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
// The value, once it has stopped changing for `ms`.
export function useDebounced<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return debounced;
}
+2
View File
@@ -17,6 +17,7 @@ import {
Flame,
Gauge,
HeartPulse,
Inbox,
LayoutDashboard,
Mailbox,
Megaphone,
@@ -73,6 +74,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: "/warmup", label: "Warmup", icon: Flame, end: true, perm: AdminPerm.ViewWarmupPool },
{ to: "/warmup/appeals", label: "Warmup Appeals", icon: ShieldCheck, perm: AdminPerm.ReviewAppeals },
{ to: "/warmup-content", label: "Warmup Content", icon: Sparkles, perm: AdminPerm.ViewWarmupPool },
{ to: "/placement", label: "Seed panel", icon: Inbox, perm: AdminPerm.ViewWarmupPool },
{ to: "/campaigns", label: "Campaigns", icon: Megaphone, perm: AdminPerm.ViewCampaigns },
{ to: "/sends", label: "Sends", icon: SendHorizonal, perm: AdminPerm.ViewCampaigns },
],
@@ -151,6 +151,15 @@ export interface InstanceSettings {
enforce_domain_auth: boolean;
auth_grace_hours: number;
};
// Inbox placement tests. The monthly allowances count tests on the
// instance and cloud panels only, and a self-hosted instance does not
// meter at all. Zero or negative on write resolves to the default.
placement: {
tests_per_month_trial: number;
tests_per_month_paid: number;
seeds_per_test: number;
spacing_seconds: number;
};
// Operator notification channels. Targets and secrets are redacted on
// read: a chat webhook URL is a bearer credential, so the server returns a
// recognisable preview and treats the preview (or an empty string) as
+177
View File
@@ -0,0 +1,177 @@
// /admin/placement/*: the instance seed panel and every workspace's inbox
// placement tests. Shapes mirror internal/app/placement/view.go and
// internal/models/placement.go.
import { Request } from "@/lib/api/client";
export type PlacementPanel = "instance" | "workspace" | "cloud";
export type PlacementOrigin = "manual" | "monitor" | "admin" | "remote";
export type PlacementStatus = "running" | "completed" | "cancelled" | "failed";
export type PlacementTracking = "campaign" | "on" | "off" | "compare";
export type PlacementFolder =
| "pending"
| "inbox"
| "promotions"
| "other"
| "spam"
| "missing"
| "failed"
| "cancelled";
export interface PlacementCounts {
total: number;
pending: number;
inbox: number;
promotions: number;
other: number;
spam: number;
missing: number;
failed: number;
cancelled: number;
delivered: number;
// Fractions 0..1 of delivered; null until something is delivered.
inbox_rate: number | null;
tabs_rate: number | null;
spam_rate: number | null;
missing_rate: number | null;
}
export interface PlacementFamilyCounts {
family: string;
label: string;
counts: PlacementCounts;
}
export interface PlacementTestView {
id: string;
sender_account_id: string | null;
sender_email: string;
created_by: string | null;
campaign_id: string | null;
sequence_id: string | null;
contact_id: string | null;
monitor_id: string | null;
subject: string;
body_html?: string;
body_plain?: string;
open_tracking: boolean;
link_tracking: boolean;
compare_group_id: string | null;
origin: PlacementOrigin;
panel: PlacementPanel;
status: PlacementStatus;
error?: string;
created_at: string;
finished_at: string | null;
summary: PlacementCounts;
families: PlacementFamilyCounts[] | null;
}
export interface PlacementResultView {
seed: string;
family: string;
family_label: string;
folder: PlacementFolder;
scheduled_at: string | null;
sent_at: string | null;
detected_at: string | null;
error?: string;
}
export interface PlacementContentIssue {
severity: "warn" | "high";
code: string;
message: string;
field?: string;
suggestion?: string;
}
export interface PlacementTestDetail extends PlacementTestView {
results: PlacementResultView[] | null;
content: { score: number; issues: PlacementContentIssue[] | null };
compare?: PlacementTestView;
}
export type AdminSeedScope = "" | "instance" | "workspace";
export interface AdminPlacementSeed {
id: string;
organization_id: string | null;
email: string;
name: string;
provider: string;
family: string;
family_label: string;
status: string;
worker_id: string | null;
seed_scope: AdminSeedScope;
}
export interface PlacementTestsPage {
data: PlacementTestView[] | null;
pagination: { total: number; has_more: boolean; next_cursor: string | null };
}
export interface AdminCreatePlacementTestBody {
sender_account_id: string;
subject: string;
body_html?: string;
body_plain?: string;
tracking?: PlacementTracking;
}
export function listPlacementTests(cursor?: string, limit = 25): Promise<PlacementTestsPage> {
const usp = new URLSearchParams();
if (cursor) usp.set("cursor", cursor);
usp.set("limit", String(limit));
return Request({
method: "GET",
url: `/admin/placement/tests?${usp.toString()}`,
authorization: true,
});
}
export function getPlacementTest(id: string): Promise<PlacementTestDetail> {
return Request<{ data: PlacementTestDetail }>({
method: "GET",
url: `/admin/placement/tests/${id}`,
authorization: true,
}).then((r) => r.data);
}
export function createPlacementTest(body: AdminCreatePlacementTestBody): Promise<PlacementTestView[]> {
return Request<{ data: PlacementTestView[] | null }>({
method: "POST",
url: "/admin/placement/tests",
data: body,
authorization: true,
}).then((r) => r.data ?? []);
}
export function listPlacementSeeds(): Promise<AdminPlacementSeed[]> {
return Request<{ data: AdminPlacementSeed[] | null }>({
method: "GET",
url: "/admin/placement/seeds",
authorization: true,
}).then((r) => r.data ?? []);
}
export function searchPlacementSeedCandidates(search: string, limit = 50): Promise<AdminPlacementSeed[]> {
const usp = new URLSearchParams();
if (search) usp.set("search", search);
usp.set("limit", String(limit));
return Request<{ data: AdminPlacementSeed[] | null }>({
method: "GET",
url: `/admin/placement/seeds/candidates?${usp.toString()}`,
authorization: true,
}).then((r) => r.data ?? []);
}
export function setPlacementSeed(id: string, seed: boolean): Promise<AdminPlacementSeed> {
return Request<{ data: AdminPlacementSeed }>({
method: "POST",
url: `/admin/placement/seeds/${id}`,
data: { seed },
authorization: true,
}).then((r) => r.data);
}
@@ -28,6 +28,11 @@ const SPINE: SpineGroup[] = [
["admin", "mailboxes"],
],
},
{
id: "placement",
match: (n) => n.includes("PLACEMENT"),
keys: [["admin", "placement"]],
},
{
id: "campaigns",
match: (n) => n.includes("CAMPAIGN"),
+2
View File
@@ -41,6 +41,7 @@ import WarmupContentLayout from "@/app/dashboard/warmup-content/WarmupContentLay
import WarmupContentOverviewPage from "@/app/dashboard/warmup-content/OverviewPage";
import WarmupContentLibraryPage from "@/app/dashboard/warmup-content/LibraryPage";
import WarmupContentJobsPage from "@/app/dashboard/warmup-content/JobsPage";
import PlacementPage from "@/app/dashboard/PlacementPage";
import CampaignsPage from "@/app/dashboard/CampaignsPage";
import SendsPage from "@/app/dashboard/SendsPage";
import LimitRequestsPage from "@/app/dashboard/LimitRequestsPage";
@@ -153,6 +154,7 @@ const router = createBrowserRouter([
{ path: "jobs", element: <WarmupContentJobsPage /> },
],
},
{ path: "placement", element: gated(AdminPerm.ViewWarmupPool, <PlacementPage />) },
{ path: "campaigns", element: gated(AdminPerm.ViewCampaigns, <CampaignsPage />) },
{ path: "sends", element: gated(AdminPerm.ViewCampaigns, <SendsPage />) },