SES volume
This commit is contained in:
@@ -1,29 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||
import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
|
||||
import { getDomainVolumeForMonth, currentYm } from '../services/ses-events.js';
|
||||
import {
|
||||
getDomainVolumeForMonth,
|
||||
getVolumeOverview,
|
||||
currentYm,
|
||||
previousYm,
|
||||
} from '../services/ses-events.js';
|
||||
|
||||
export const billingRouter = Router();
|
||||
billingRouter.use(requireAuth);
|
||||
billingRouter.use(requireSuperAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/billing/summary?domain=foo.com
|
||||
* Inbox-count based monthly summary ($5 per inbox per month).
|
||||
*/
|
||||
billingRouter.get('/summary', async (req, res) => {
|
||||
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : undefined;
|
||||
const months = await computeMonthlyBilling({ domain });
|
||||
res.json({
|
||||
price_per_inbox: PRICE_PER_INBOX,
|
||||
months,
|
||||
});
|
||||
res.json({ price_per_inbox: PRICE_PER_INBOX, months });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/billing/events?domain=foo.com
|
||||
* Raw mailbox lifecycle event log (created/deleted).
|
||||
*/
|
||||
billingRouter.get('/events', async (req, res) => {
|
||||
const events = await listBillingEvents({
|
||||
domain: req.query.domain ? String(req.query.domain).toLowerCase() : undefined,
|
||||
@@ -36,13 +30,7 @@ billingRouter.get('/events', async (req, res) => {
|
||||
|
||||
/**
|
||||
* GET /api/billing/volume?domain=foo.com&ym=2026-04
|
||||
* SES outbound volume for a domain in a specific month.
|
||||
*
|
||||
* Returns per-inbox + domain totals with send count, total bytes,
|
||||
* bounce count and complaint count.
|
||||
*
|
||||
* Defaults to the current month if ym is omitted.
|
||||
* Domain is required because volume is always per-domain.
|
||||
* Per-inbox drilldown for a single domain.
|
||||
*/
|
||||
billingRouter.get('/volume', async (req, res) => {
|
||||
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : '';
|
||||
@@ -51,16 +39,38 @@ billingRouter.get('/volume', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ym = req.query.ym
|
||||
? String(req.query.ym)
|
||||
: currentYm();
|
||||
|
||||
// Validate ym format
|
||||
const ym = req.query.ym ? String(req.query.ym) : currentYm();
|
||||
if (!/^\d{4}-\d{2}$/.test(ym)) {
|
||||
res.status(400).json({ error: 'ym must be in YYYY-MM format' });
|
||||
return;
|
||||
}
|
||||
|
||||
const volume = await getDomainVolumeForMonth(domain, ym);
|
||||
res.json(volume);
|
||||
res.json(await getDomainVolumeForMonth(domain, ym));
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/billing/volume-overview?ym=2026-04
|
||||
* Cross-domain overview for super admin. One DynamoDB scan, aggregates
|
||||
* per domain. ym defaults to the current month.
|
||||
*
|
||||
* Returns:
|
||||
* {
|
||||
* ym: 'YYYY-MM',
|
||||
* total_send_count, total_bounce_count, total_complaint_count, total_inbox_count,
|
||||
* rows: [{ domain, send_count, bytes_total, bounce_count, complaint_count, inbox_count }, ...]
|
||||
* }
|
||||
*/
|
||||
billingRouter.get('/volume-overview', async (req, res) => {
|
||||
// Limit to current/previous month per product decision — no point in
|
||||
// accepting arbitrary ym values from the client right now.
|
||||
const ym = req.query.ym ? String(req.query.ym) : currentYm();
|
||||
const allowed = new Set([currentYm(), previousYm()]);
|
||||
if (!allowed.has(ym)) {
|
||||
res.status(400).json({
|
||||
error: `ym must be one of: ${[...allowed].join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await getVolumeOverview(ym));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
|
||||
import { DynamoDBDocumentClient, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
||||
import { config } from '../config.js';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
@@ -13,7 +13,7 @@ const doc = DynamoDBDocumentClient.from(
|
||||
export interface InboxVolume {
|
||||
email: string;
|
||||
domain: string;
|
||||
ym: string; // 'YYYY-MM'
|
||||
ym: string;
|
||||
send_count: number;
|
||||
bytes_total: number;
|
||||
bounce_count: number;
|
||||
@@ -31,15 +31,27 @@ export interface DomainVolume {
|
||||
}
|
||||
|
||||
interface RawEventRow {
|
||||
pk?: string;
|
||||
event_type: 'send' | 'bounce' | 'complaint';
|
||||
size_bytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all events for a single (email, ym) bucket and aggregate them
|
||||
* into counts. This is the lowest-level helper used by the higher-level
|
||||
* domain aggregator below.
|
||||
*/
|
||||
export function currentYm(): string {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function previousYm(): string {
|
||||
const now = new Date();
|
||||
// Construct first day of previous month in UTC, then format.
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Single-domain volume (used for the per-inbox drilldown)
|
||||
// ============================================================
|
||||
|
||||
async function aggregateInbox(domain: string, email: string, ym: string): Promise<InboxVolume> {
|
||||
const pk = `${domain}#${email}#${ym}`;
|
||||
|
||||
@@ -49,16 +61,12 @@ async function aggregateInbox(domain: string, email: string, ym: string): Promis
|
||||
let bounce_count = 0;
|
||||
let complaint_count = 0;
|
||||
|
||||
// DynamoDB Query is paginated — loop until done. For a single
|
||||
// mailbox-month the result set is normally small (< few thousand)
|
||||
// so this loops at most a couple of times.
|
||||
do {
|
||||
const resp = await doc.send(new QueryCommand({
|
||||
TableName: TABLE_NAME,
|
||||
KeyConditionExpression: 'pk = :pk',
|
||||
ExpressionAttributeValues: { ':pk': pk },
|
||||
ExclusiveStartKey: lastEvaluatedKey,
|
||||
// We only need a few fields for aggregation, not the full row.
|
||||
ProjectionExpression: 'event_type, size_bytes',
|
||||
}));
|
||||
|
||||
@@ -79,30 +87,25 @@ async function aggregateInbox(domain: string, email: string, ym: string): Promis
|
||||
return { email, domain, ym, send_count, bytes_total, bounce_count, complaint_count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full domain volume for a given month.
|
||||
*
|
||||
* Looks up all mailboxes (active + deleted) that ever existed in the
|
||||
* domain — we use the mailboxes table for that, which is cheap. Then
|
||||
* for each mailbox we aggregate the events. Mailboxes with zero events
|
||||
* are omitted from the per_inbox list (but they're free anyway).
|
||||
*/
|
||||
export async function getDomainVolumeForMonth(domain: string, ym: string): Promise<DomainVolume> {
|
||||
const d = domain.toLowerCase();
|
||||
|
||||
// Pull every mailbox that ever existed for this domain. Even
|
||||
// soft-deleted ones may have sent mails before deletion, and we
|
||||
// want to show those in historical months.
|
||||
const result = await pool.query(
|
||||
`SELECT email_address FROM mailboxes WHERE domain=$1`,
|
||||
[d],
|
||||
);
|
||||
const emails: string[] = result.rows.map((r: any) => String(r.email_address));
|
||||
const emailsFromPg: string[] = result.rows.map((r: any) => String(r.email_address));
|
||||
|
||||
// Aggregate in parallel — DynamoDB is fine with this.
|
||||
const perInbox = await Promise.all(emails.map((e) => aggregateInbox(d, e, ym)));
|
||||
// Also discover any email addresses that have events in DynamoDB but are
|
||||
// no longer in the local PostgreSQL (e.g. mailboxes that were created on
|
||||
// a different node, or hard-deleted from PostgreSQL but still have events).
|
||||
// We do a targeted Scan filtered by the domain prefix on pk.
|
||||
const emailsFromDdb = await listEmailsForDomain(d, ym);
|
||||
|
||||
const allEmails = Array.from(new Set([...emailsFromPg, ...emailsFromDdb]));
|
||||
|
||||
const perInbox = await Promise.all(allEmails.map((e) => aggregateInbox(d, e, ym)));
|
||||
|
||||
// Drop empty entries; sort the rest by send_count desc.
|
||||
const nonEmpty = perInbox.filter(
|
||||
(v) => v.send_count > 0 || v.bounce_count > 0 || v.complaint_count > 0,
|
||||
);
|
||||
@@ -118,19 +121,148 @@ export async function getDomainVolumeForMonth(domain: string, ym: string): Promi
|
||||
{ send_count: 0, bytes_total: 0, bounce_count: 0, complaint_count: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
domain: d,
|
||||
ym,
|
||||
...totals,
|
||||
per_inbox: nonEmpty,
|
||||
};
|
||||
return { domain: d, ym, ...totals, per_inbox: nonEmpty };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: get volume for the current month across all mailboxes
|
||||
* in a domain.
|
||||
* Find the set of email addresses that have any event for the given
|
||||
* (domain, ym) bucket. Used to ensure the per-inbox drilldown surfaces
|
||||
* historical mailboxes that no longer exist in the local PostgreSQL.
|
||||
*
|
||||
* Uses a Scan with a begins_with filter on pk because we don't have
|
||||
* a GSI on domain. For typical event volumes this is fast enough; a
|
||||
* GSI can be added later if it becomes a hot spot.
|
||||
*/
|
||||
export function currentYm(): string {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
async function listEmailsForDomain(domain: string, ym: string): Promise<string[]> {
|
||||
const prefix = `${domain}#`;
|
||||
const suffix = `#${ym}`;
|
||||
const emails = new Set<string>();
|
||||
let lastEvaluatedKey: Record<string, unknown> | undefined;
|
||||
|
||||
do {
|
||||
const resp = await doc.send(new ScanCommand({
|
||||
TableName: TABLE_NAME,
|
||||
FilterExpression: 'begins_with(pk, :p)',
|
||||
ExpressionAttributeValues: { ':p': prefix },
|
||||
ProjectionExpression: 'pk',
|
||||
ExclusiveStartKey: lastEvaluatedKey,
|
||||
}));
|
||||
|
||||
for (const row of (resp.Items ?? []) as { pk?: string }[]) {
|
||||
if (!row.pk) continue;
|
||||
// pk format: "domain#email#YYYY-MM"
|
||||
if (!row.pk.endsWith(suffix)) continue;
|
||||
const inner = row.pk.slice(prefix.length, row.pk.length - suffix.length);
|
||||
if (inner) emails.add(inner);
|
||||
}
|
||||
|
||||
lastEvaluatedKey = resp.LastEvaluatedKey;
|
||||
} while (lastEvaluatedKey);
|
||||
|
||||
return [...emails];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// All-domains overview (single Scan, no per-domain Query loop)
|
||||
// ============================================================
|
||||
|
||||
export interface DomainOverviewRow {
|
||||
domain: string;
|
||||
send_count: number;
|
||||
bytes_total: number;
|
||||
bounce_count: number;
|
||||
complaint_count: number;
|
||||
inbox_count: number;
|
||||
}
|
||||
|
||||
export interface VolumeOverview {
|
||||
ym: string;
|
||||
total_send_count: number;
|
||||
total_bounce_count: number;
|
||||
total_complaint_count: number;
|
||||
total_inbox_count: number;
|
||||
rows: DomainOverviewRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-domain overview. One Scan reads all events for the given month,
|
||||
* aggregated per domain. We rely on the fact that pk encodes both the
|
||||
* domain and the ym, so a server-side FilterExpression cuts the scanned
|
||||
* set down to just this month.
|
||||
*
|
||||
* Note: DynamoDB Scan still reads the entire table for billing, but the
|
||||
* filter reduces network bytes returned. For our event volumes that's
|
||||
* acceptable. If the table grows past a few hundred thousand items per
|
||||
* month we should add a GSI keyed on (ym).
|
||||
*/
|
||||
export async function getVolumeOverview(ym: string): Promise<VolumeOverview> {
|
||||
const suffix = `#${ym}`;
|
||||
let lastEvaluatedKey: Record<string, unknown> | undefined;
|
||||
|
||||
// Per-domain accumulators with a Set for unique emails.
|
||||
const acc = new Map<string, {
|
||||
send: number; bytes: number; bounce: number; complaint: number; inboxes: Set<string>;
|
||||
}>();
|
||||
|
||||
do {
|
||||
const resp = await doc.send(new ScanCommand({
|
||||
TableName: TABLE_NAME,
|
||||
// Filter at the server: only pk values ending in this month.
|
||||
// DynamoDB doesn't support "ends_with" on a key, so we use
|
||||
// contains() — works because '#2026-04' is unique enough.
|
||||
FilterExpression: 'contains(pk, :ym)',
|
||||
ExpressionAttributeValues: { ':ym': suffix },
|
||||
ProjectionExpression: 'pk, event_type, size_bytes',
|
||||
ExclusiveStartKey: lastEvaluatedKey,
|
||||
}));
|
||||
|
||||
for (const row of (resp.Items ?? []) as RawEventRow[]) {
|
||||
if (!row.pk || !row.pk.endsWith(suffix)) continue;
|
||||
// Strip the trailing "#ym" then split into [domain, email].
|
||||
const head = row.pk.slice(0, row.pk.length - suffix.length);
|
||||
const sep = head.indexOf('#');
|
||||
if (sep < 0) continue;
|
||||
const domain = head.slice(0, sep);
|
||||
const email = head.slice(sep + 1);
|
||||
|
||||
let entry = acc.get(domain);
|
||||
if (!entry) {
|
||||
entry = { send: 0, bytes: 0, bounce: 0, complaint: 0, inboxes: new Set() };
|
||||
acc.set(domain, entry);
|
||||
}
|
||||
entry.inboxes.add(email);
|
||||
|
||||
if (row.event_type === 'send') {
|
||||
entry.send++;
|
||||
entry.bytes += Number(row.size_bytes ?? 0);
|
||||
} else if (row.event_type === 'bounce') {
|
||||
entry.bounce++;
|
||||
} else if (row.event_type === 'complaint') {
|
||||
entry.complaint++;
|
||||
}
|
||||
}
|
||||
|
||||
lastEvaluatedKey = resp.LastEvaluatedKey;
|
||||
} while (lastEvaluatedKey);
|
||||
|
||||
const rows: DomainOverviewRow[] = [...acc.entries()].map(([domain, e]) => ({
|
||||
domain,
|
||||
send_count: e.send,
|
||||
bytes_total: e.bytes,
|
||||
bounce_count: e.bounce,
|
||||
complaint_count: e.complaint,
|
||||
inbox_count: e.inboxes.size,
|
||||
})).sort((a, b) => b.send_count - a.send_count || a.domain.localeCompare(b.domain));
|
||||
|
||||
const totals = rows.reduce(
|
||||
(s, r) => ({
|
||||
total_send_count: s.total_send_count + r.send_count,
|
||||
total_bounce_count: s.total_bounce_count + r.bounce_count,
|
||||
total_complaint_count: s.total_complaint_count + r.complaint_count,
|
||||
total_inbox_count: s.total_inbox_count + r.inbox_count,
|
||||
}),
|
||||
{ total_send_count: 0, total_bounce_count: 0, total_complaint_count: 0, total_inbox_count: 0 },
|
||||
);
|
||||
|
||||
return { ym, ...totals, rows };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user