SES volume

This commit is contained in:
2026-04-30 09:50:18 -05:00
parent cd2bbe9b7d
commit c44d3228c6
4 changed files with 485 additions and 180 deletions

View File

@@ -1,8 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
FiDollarSign, FiList, FiPlus, FiMinus, FiInbox, FiSend,
FiAlertCircle, FiAlertTriangle,
FiAlertCircle, FiAlertTriangle, FiArrowLeft, FiArrowRight,
} from 'react-icons/fi';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import Modal from './Modal';
import LoadingOverlay from './LoadingOverlay';
import { billingAPI } from '../services/api';
@@ -22,14 +25,28 @@ const currentYm = () => {
const d = new Date();
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
};
const previousYm = () => {
const now = new Date();
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
};
const ymLabel = (ym) => {
const [y, m] = ym.split('-').map(Number);
return `${MONTH_NAMES[m - 1]} ${y}`;
};
const BillingModal = ({ open, domains, onClose, onToast }) => {
const [activeTab, setActiveTab] = useState('summary');
const [domainFilter, setDomainFilter] = useState('');
const [ymFilter, setYmFilter] = useState(currentYm());
const [domainFilter, setDomainFilter] = useState(''); // for summary/events
const [ymFilter, setYmFilter] = useState(currentYm()); // for volume
const [summary, setSummary] = useState(null);
const [events, setEvents] = useState([]);
const [volume, setVolume] = useState(null);
// Volume tab state: overview vs drilldown
const [overview, setOverview] = useState(null);
const [drilldownDomain, setDrilldownDomain] = useState(null);
const [drilldownData, setDrilldownData] = useState(null);
const [loading, setLoading] = useState(false);
const sortedDomains = useMemo(
@@ -37,43 +54,43 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
[domains]
);
// Build a list of months to choose from, going back ~12 months from now.
const ymOptions = useMemo(() => {
const out = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
const ym = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
out.push({ ym, label: `${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}` });
}
return out;
}, []);
// Volume tab is restricted to current + previous month (per product decision).
const ymOptions = useMemo(() => ([
{ ym: currentYm(), label: ymLabel(currentYm()) },
{ ym: previousYm(), label: ymLabel(previousYm()) },
]), []);
const reload = async () => {
if (!open) return;
const reloadSummary = async () => {
setLoading(true);
try {
const filter = domainFilter || undefined;
if (activeTab === 'summary') {
setSummary(await billingAPI.summary(filter));
} else if (activeTab === 'events') {
setEvents(await billingAPI.events({ domain: filter, limit: 500 }));
} else if (activeTab === 'volume') {
if (!filter) {
// Volume requires a specific domain.
setVolume(null);
} else {
setVolume(await billingAPI.volume({ domain: filter, ym: ymFilter }));
}
}
} catch (err) {
onToast?.(`Failed to load: ${err.message}`, 'error');
} finally {
setLoading(false);
}
setSummary(await billingAPI.summary(domainFilter || undefined));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
const reloadEvents = async () => {
setLoading(true);
try {
setEvents(await billingAPI.events({ domain: domainFilter || undefined, limit: 500 }));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
const reloadOverview = async () => {
setLoading(true);
try {
setOverview(await billingAPI.volumeOverview({ ym: ymFilter }));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
const reloadDrilldown = async () => {
if (!drilldownDomain) return;
setLoading(true);
try {
setDrilldownData(await billingAPI.volume({ domain: drilldownDomain, ym: ymFilter }));
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
finally { setLoading(false); }
};
// Reset everything when modal opens
useEffect(() => {
if (open) {
setActiveTab('summary');
@@ -81,15 +98,23 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
setYmFilter(currentYm());
setSummary(null);
setEvents([]);
setVolume(null);
setOverview(null);
setDrilldownDomain(null);
setDrilldownData(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Load data depending on which view is active
useEffect(() => {
reload();
if (!open) return;
if (activeTab === 'summary') reloadSummary();
else if (activeTab === 'events') reloadEvents();
else if (activeTab === 'volume') {
if (drilldownDomain) reloadDrilldown();
else reloadOverview();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeTab, domainFilter, ymFilter]);
}, [open, activeTab, domainFilter, ymFilter, drilldownDomain]);
return (
<Modal
@@ -108,7 +133,12 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
return (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
onClick={() => {
setActiveTab(t.id);
// Always reset drilldown when switching tabs.
setDrilldownDomain(null);
setDrilldownData(null);
}}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
@@ -125,19 +155,21 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
{/* Filters */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm font-semibold text-gray-700">Domain:</label>
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="input-field max-w-xs py-2"
>
<option value="">{activeTab === 'volume' ? 'Select a domain...' : 'All domains'}</option>
{sortedDomains.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
{(activeTab === 'summary' || activeTab === 'events') && (
<div className="flex items-center gap-2">
<label className="text-sm font-semibold text-gray-700">Domain:</label>
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="input-field max-w-xs py-2"
>
<option value="">All domains</option>
{sortedDomains.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
)}
{activeTab === 'volume' && (
<div className="flex items-center gap-2">
@@ -159,12 +191,20 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
{!loading && activeTab === 'summary' && <SummaryView summary={summary} />}
{!loading && activeTab === 'events' && <EventsView events={events} />}
{!loading && activeTab === 'volume' && (
<VolumeView
volume={volume}
domain={domainFilter}
ymOptions={ymOptions}
{!loading && activeTab === 'volume' && !drilldownDomain && (
<OverviewView
overview={overview}
ymFilter={ymFilter}
onPickDomain={(d) => setDrilldownDomain(d)}
/>
)}
{!loading && activeTab === 'volume' && drilldownDomain && (
<DrilldownView
volume={drilldownData}
domain={drilldownDomain}
ymFilter={ymFilter}
onBack={() => { setDrilldownDomain(null); setDrilldownData(null); }}
/>
)}
</div>
@@ -173,36 +213,31 @@ const BillingModal = ({ open, domains, onClose, onToast }) => {
};
// ============================================================
// Sub: Summary
// Sub: Monthly summary (unchanged)
// ============================================================
const SummaryView = ({ summary }) => {
if (!summary) return null;
const { months, price_per_inbox } = summary;
if (!months || months.length === 0) {
return <p className="text-sm text-gray-500 text-center py-12">No billable activity yet.</p>;
}
const byYm = new Map();
for (const row of months) {
if (!byYm.has(row.ym)) byYm.set(row.ym, []);
byYm.get(row.ym).push(row);
}
const sortedYms = [...byYm.keys()].sort().reverse();
const latestYm = sortedYms[0];
const latestRows = byYm.get(latestYm) || [];
const latestTotalInboxes = latestRows.reduce((s, r) => s + r.inbox_count, 0);
const latestTotalUsd = latestRows.reduce((s, r) => s + r.amount_usd, 0);
const [latestY, latestM] = latestYm ? latestYm.split('-').map(Number) : [null, null];
return (
<div className="space-y-6">
{latestYm && (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
{MONTH_NAMES[latestM - 1]} {latestY}
{ymLabel(latestYm)}
</div>
<div className="mt-1 flex items-baseline gap-3">
<span className="text-3xl font-bold text-gray-900">${latestTotalUsd}</span>
@@ -216,16 +251,12 @@ const SummaryView = ({ summary }) => {
<div className="space-y-5">
{sortedYms.map((ym) => {
const rows = byYm.get(ym);
const [y, m] = ym.split('-').map(Number);
const monthTotalInboxes = rows.reduce((s, r) => s + r.inbox_count, 0);
const monthTotalUsd = rows.reduce((s, r) => s + r.amount_usd, 0);
return (
<div key={ym}>
<div className="flex items-baseline justify-between mb-2 border-b border-gray-200 pb-1">
<h3 className="text-sm font-bold text-gray-800">
{MONTH_NAMES[m - 1]} {y}
</h3>
<h3 className="text-sm font-bold text-gray-800">{ymLabel(ym)}</h3>
<span className="text-sm text-gray-600">
<span className="font-mono">{monthTotalInboxes}</span> inboxes ·{' '}
<span className="font-bold text-gray-900">${monthTotalUsd}</span>
@@ -237,9 +268,7 @@ const SummaryView = ({ summary }) => {
.sort((a, b) => b.amount_usd - a.amount_usd || a.domain.localeCompare(b.domain))
.map((r) => (
<tr key={`${r.ym}-${r.domain}`} className="hover:bg-gray-50">
<td className="py-2 pr-3">
<span className="font-medium text-gray-900">{r.domain}</span>
</td>
<td className="py-2 pr-3"><span className="font-medium text-gray-900">{r.domain}</span></td>
<td className="py-2 pr-3 text-gray-600 text-xs whitespace-nowrap">
<FiInbox className="inline w-3 h-3 mr-1 mb-0.5" />
{r.inbox_count} inbox{r.inbox_count !== 1 ? 'es' : ''}
@@ -260,13 +289,12 @@ const SummaryView = ({ summary }) => {
};
// ============================================================
// Sub: Events
// Sub: Event log (unchanged)
// ============================================================
const EventsView = ({ events }) => {
if (!events || events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-12">No events yet.</p>;
}
return (
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
<table className="w-full text-sm">
@@ -310,42 +338,187 @@ const EventsView = ({ events }) => {
};
// ============================================================
// Sub: Volume (SES outbound stats per inbox)
// Sub: Volume Overview — Chart + sortable table across all domains
// ============================================================
const VolumeView = ({ volume, domain, ymFilter }) => {
if (!domain) {
const OverviewView = ({ overview, ymFilter, onPickDomain }) => {
const [sortKey, setSortKey] = useState('send_count');
const [sortDir, setSortDir] = useState('desc');
if (!overview) return null;
const monthLabel = ymLabel(ymFilter);
if (!overview.rows || overview.rows.length === 0) {
return (
<p className="text-sm text-gray-500 text-center py-12">
Pick a domain to see SES outbound volume.
</p>
<div className="space-y-4">
<Headline ym={monthLabel} totals={overview} />
<p className="text-sm text-gray-500 text-center py-12">
No SES events recorded for {monthLabel}.
</p>
</div>
);
}
// Top-10 for the chart (always sorted by send_count desc)
const top10 = [...overview.rows]
.sort((a, b) => b.send_count - a.send_count)
.slice(0, 10);
const sorted = [...overview.rows].sort((a, b) => {
const av = a[sortKey] ?? 0;
const bv = b[sortKey] ?? 0;
if (typeof av === 'string') {
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
}
return sortDir === 'asc' ? av - bv : bv - av;
});
const headerCell = (key, label, alignRight = false) => (
<th
onClick={() => {
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('desc'); }
}}
className={`py-2 pr-4 font-semibold cursor-pointer select-none hover:text-gray-900 ${alignRight ? 'text-right' : ''}`}
title="Click to sort"
>
{label}
{sortKey === key && <span className="ml-1 text-gray-400">{sortDir === 'asc' ? '↑' : '↓'}</span>}
</th>
);
return (
<div className="space-y-5">
<Headline ym={monthLabel} totals={overview} />
{/* Chart */}
<div>
<h4 className="text-xs uppercase tracking-wide text-gray-500 font-semibold mb-2">
Top {Math.min(10, top10.length)} domains by send volume
</h4>
<div className="h-64 w-full">
<ResponsiveContainer>
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#e5e7eb" />
<XAxis type="number" stroke="#6b7280" fontSize={12} />
<YAxis
type="category"
dataKey="domain"
stroke="#6b7280"
fontSize={12}
width={140}
/>
<Tooltip
contentStyle={{ borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12 }}
formatter={(value, name) => [Number(value).toLocaleString(), 'Sends']}
/>
<Bar dataKey="send_count" fill="#0284c7" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Table */}
<div>
<h4 className="text-xs uppercase tracking-wide text-gray-500 font-semibold mb-2">
All domains ({overview.rows.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
{headerCell('domain', 'Domain')}
{headerCell('inbox_count', 'Inboxes', true)}
{headerCell('send_count', 'Sent', true)}
{headerCell('bounce_count', 'Bounces', true)}
{headerCell('complaint_count', 'Complaints', true)}
<th className="py-2 pr-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.map((r) => (
<tr key={r.domain} className="hover:bg-gray-50">
<td className="py-2 pr-4">
<span className="font-medium text-gray-900">{r.domain}</span>
</td>
<td className="py-2 pr-4 text-right font-mono text-gray-700">
{r.inbox_count.toLocaleString()}
</td>
<td className="py-2 pr-4 text-right font-mono">{r.send_count.toLocaleString()}</td>
<td className={`py-2 pr-4 text-right font-mono ${r.bounce_count > 0 ? 'text-amber-700' : 'text-gray-400'}`}>
{r.bounce_count.toLocaleString()}
</td>
<td className={`py-2 pr-4 text-right font-mono ${r.complaint_count > 0 ? 'text-red-700 font-semibold' : 'text-gray-400'}`}>
{r.complaint_count.toLocaleString()}
</td>
<td className="py-2 pr-2 text-right">
<button
onClick={() => onPickDomain(r.domain)}
className="btn-ghost px-2 py-1"
title="Show per-inbox breakdown"
>
Details <FiArrowRight className="inline w-3.5 h-3.5 ml-1" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
const Headline = ({ ym, totals }) => (
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
{ym} · all domains
</div>
<div className="mt-2 grid grid-cols-1 sm:grid-cols-4 gap-4">
<Stat label="Inboxes" value={(totals.total_inbox_count ?? 0).toLocaleString()} icon={FiInbox} />
<Stat label="Sent" value={(totals.total_send_count ?? 0).toLocaleString()} icon={FiSend} />
<Stat
label="Bounces"
value={(totals.total_bounce_count ?? 0).toLocaleString()}
icon={FiAlertCircle}
color={totals.total_bounce_count > 0 ? 'text-amber-600' : ''}
/>
<Stat
label="Complaints"
value={(totals.total_complaint_count ?? 0).toLocaleString()}
icon={FiAlertTriangle}
color={totals.total_complaint_count > 0 ? 'text-red-600' : ''}
/>
</div>
</div>
);
// ============================================================
// Sub: Drilldown — per-inbox view for one domain
// ============================================================
const DrilldownView = ({ volume, domain, ymFilter, onBack }) => {
if (!volume) return null;
const monthLabel = ymLabel(ymFilter);
const monthLabel = (() => {
const [y, m] = ymFilter.split('-').map(Number);
return `${MONTH_NAMES[m - 1]} ${y}`;
})();
// Bounce/complaint warnings: SES soft-cap is roughly 5% bounce rate
// and 0.1% complaint rate. We show a small icon when the per-inbox
// numbers exceed those thresholds.
const isProblematic = (v) => {
if (v.send_count === 0) return false;
const bounceRate = v.bounce_count / v.send_count;
const complaintRate = v.complaint_count / v.send_count;
return bounceRate > 0.05 || complaintRate > 0.001;
return (v.bounce_count / v.send_count) > 0.05 || (v.complaint_count / v.send_count) > 0.001;
};
return (
<div className="space-y-5">
{/* Domain totals headline */}
<button onClick={onBack} className="btn-ghost -ml-2">
<FiArrowLeft className="w-4 h-4 mr-1.5" />
Back to overview
</button>
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
{domain} · {monthLabel}
</div>
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
<Stat label="Sent" value={volume.send_count.toLocaleString()} icon={FiSend} />
<Stat label="Sent" value={volume.send_count.toLocaleString()} icon={FiSend} />
<Stat
label="Bounces"
value={volume.bounce_count.toLocaleString()}
@@ -361,7 +534,6 @@ const VolumeView = ({ volume, domain, ymFilter }) => {
</div>
</div>
{/* Per-inbox breakdown */}
{volume.per_inbox.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-8">
No SES events recorded for {domain} in {monthLabel}.
@@ -384,10 +556,7 @@ const VolumeView = ({ volume, domain, ymFilter }) => {
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-800">{v.email}</span>
{isProblematic(v) && (
<FiAlertTriangle
className="w-3.5 h-3.5 text-amber-500"
title="High bounce or complaint rate"
/>
<FiAlertTriangle className="w-3.5 h-3.5 text-amber-500" title="High bounce or complaint rate" />
)}
</div>
</td>

View File

@@ -46,29 +46,17 @@ export const mailboxesAPI = {
remove: async (email) =>
(await api.delete(`/api/mailboxes/${encodeURIComponent(email)}`)).data,
setPassword: async (email, password) =>
(await api.post(
`/api/mailboxes/${encodeURIComponent(email)}/password`,
{ password }
)).data,
(await api.post(`/api/mailboxes/${encodeURIComponent(email)}/password`, { password })).data,
setQuota: async (email, quota_gb) =>
(await api.post(
`/api/mailboxes/${encodeURIComponent(email)}/quota`,
{ quota_gb }
)).data,
(await api.post(`/api/mailboxes/${encodeURIComponent(email)}/quota`, { quota_gb })).data,
getRules: async (email) =>
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
putRules: async (email, payload) =>
(await api.put(
`/api/mailboxes/${encodeURIComponent(email)}/rules`,
payload
)).data,
(await api.put(`/api/mailboxes/${encodeURIComponent(email)}/rules`, payload)).data,
getBlocklist: async (email) =>
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`)).data,
putBlocklist: async (email, blocked_patterns) =>
(await api.put(
`/api/mailboxes/${encodeURIComponent(email)}/blocklist`,
{ blocked_patterns }
)).data,
(await api.put(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { blocked_patterns })).data,
};
export const auditAPI = {
@@ -107,6 +95,12 @@ export const billingAPI = {
if (ym) params.set('ym', ym);
return (await api.get(`/api/billing/volume?${params.toString()}`)).data;
},
volumeOverview: async ({ ym } = {}) => {
const params = new URLSearchParams();
if (ym) params.set('ym', ym);
const qs = params.toString();
return (await api.get(`/api/billing/volume-overview${qs ? '?' + qs : ''}`)).data;
},
};
export const healthAPI = {