Add Woodpecker CI/CD pipeline configuration and new chart components
Introduce a Woodpecker CI/CD pipeline in `.woodpecker.yml` for automated deployment on pushes to the main branch. Add new components for visualizing campaign performance, including `DailyPerformanceChart`, `InboxPlacementChart`, and `InboxVsSpamChart`, to enhance the Campaign Detail page. Update `WarmupPlanChart` to include additional metrics and improve tooltip information. Enhance utility functions for chart data formatting and daily statistics mapping.
This commit is contained in:
parent
5b2eee84be
commit
ed3ec86025
11 changed files with 794 additions and 55 deletions
32
.woodpecker.yml
Normal file
32
.woodpecker.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Woodpecker CI — Forgejo at https://git.jonnyn.com/
|
||||
# Push to main → verify build → deploy on home server.
|
||||
#
|
||||
# Server prerequisites: see docs/deploy-ci-cd.md
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
verify:
|
||||
image: node:22-bookworm-slim
|
||||
environment:
|
||||
DATABASE_URL: file:/tmp/warmbox-ci.db
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
NODE_ENV: test
|
||||
commands:
|
||||
- npm ci
|
||||
- npx prisma generate
|
||||
- npm run build
|
||||
- npm test
|
||||
|
||||
deploy:
|
||||
image: docker:27-cli
|
||||
depends_on: [verify]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /srv/docker-configs/warmbox:/deploy
|
||||
commands:
|
||||
- apk add --no-cache git bash
|
||||
- chmod +x /deploy/scripts/deploy.sh
|
||||
- DEPLOY_DIR=/deploy DEPLOY_BRANCH=main bash /deploy/scripts/deploy.sh
|
||||
98
apps/web/src/components/DailyPerformanceChart.tsx
Normal file
98
apps/web/src/components/DailyPerformanceChart.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { DailyChartRow } from '../lib/campaign-chart-utils';
|
||||
|
||||
function PerformanceTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: DailyChartRow; name?: string; value?: number; color?: string }>;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]!.payload;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[200px]">
|
||||
<p className="font-semibold text-slate-900">{row.label}</p>
|
||||
<p className="text-xs text-slate-500 mb-2">Day {row.dayIndex}</p>
|
||||
<dl className="space-y-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Scheduled</dt>
|
||||
<dd className="font-medium tabular-nums">{row.plannedSends}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-indigo-600">Sent</dt>
|
||||
<dd className="font-medium tabular-nums text-indigo-600">{row.sends}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-emerald-600">Replied</dt>
|
||||
<dd className="font-medium tabular-nums text-emerald-600">{row.replies}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DailyPerformanceChartProps = {
|
||||
data: DailyChartRow[];
|
||||
};
|
||||
|
||||
export function DailyPerformanceChart({ data }: DailyPerformanceChartProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 py-8 text-center">
|
||||
No activity in the last 7 days yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
|
||||
<Tooltip content={<PerformanceTooltip />} />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="plannedSends"
|
||||
name="Scheduled"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="sends"
|
||||
name="Sent"
|
||||
stroke="#4f46e5"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#4f46e5' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="replies"
|
||||
name="Replied"
|
||||
stroke="#ec4899"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#ec4899' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/components/InboxPlacementChart.tsx
Normal file
88
apps/web/src/components/InboxPlacementChart.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { DailyChartRow } from '../lib/campaign-chart-utils';
|
||||
import { formatPercent } from '../lib/utils';
|
||||
|
||||
function PlacementBarTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: DailyChartRow }>;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]!.payload;
|
||||
const total = row.inbox + row.spam;
|
||||
const inboxRate = total > 0 ? (row.inbox / total) * 100 : 100;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[200px]">
|
||||
<p className="font-semibold text-slate-900">{row.label}</p>
|
||||
<p className="text-xs text-slate-500 mb-2">Day {row.dayIndex}</p>
|
||||
<dl className="space-y-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-emerald-700">Inbox</dt>
|
||||
<dd className="font-medium tabular-nums text-emerald-700">{row.inbox}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-red-600">Spam</dt>
|
||||
<dd className="font-medium tabular-nums text-red-600">{row.spam}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 pt-1 border-t border-slate-100">
|
||||
<dt className="text-slate-500">Inbox rate</dt>
|
||||
<dd className="font-medium tabular-nums">{formatPercent(inboxRate)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type InboxPlacementChartProps = {
|
||||
data: DailyChartRow[];
|
||||
};
|
||||
|
||||
export function InboxPlacementChart({ data }: InboxPlacementChartProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 py-8 text-center">
|
||||
No placement data in the last 7 days yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
|
||||
<Tooltip content={<PlacementBarTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="inbox"
|
||||
name="Inbox"
|
||||
stackId="placement"
|
||||
fill="#10b981"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="spam"
|
||||
name="Spam"
|
||||
stackId="placement"
|
||||
fill="#ef4444"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/InboxVsSpamChart.tsx
Normal file
114
apps/web/src/components/InboxVsSpamChart.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { formatPercent } from '../lib/utils';
|
||||
|
||||
const INBOX_COLOR = '#1e3a5f';
|
||||
const SPAM_COLOR = '#b91c1c';
|
||||
|
||||
type PlacementSlice = {
|
||||
name: string;
|
||||
value: number;
|
||||
percent: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type InboxVsSpamChartProps = {
|
||||
inboxCount: number;
|
||||
spamCount: number;
|
||||
};
|
||||
|
||||
function PlacementTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: PlacementSlice }>;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const slice = payload[0]!.payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-lg text-sm">
|
||||
<p className="font-medium" style={{ color: slice.color }}>
|
||||
{slice.name}
|
||||
</p>
|
||||
<p className="tabular-nums text-slate-600">
|
||||
{slice.value} emails · {formatPercent(slice.percent)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxVsSpamChart({ inboxCount, spamCount }: InboxVsSpamChartProps) {
|
||||
const total = inboxCount + spamCount;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 py-12 text-center">
|
||||
No warmup emails received yet — placement appears after the first sends land in the
|
||||
primary inbox.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const inboxPercent = (inboxCount / total) * 100;
|
||||
const spamPercent = (spamCount / total) * 100;
|
||||
|
||||
const data: PlacementSlice[] = [
|
||||
{
|
||||
name: 'In Inbox',
|
||||
value: inboxCount,
|
||||
percent: inboxPercent,
|
||||
color: INBOX_COLOR,
|
||||
},
|
||||
{
|
||||
name: 'In Spam',
|
||||
value: spamCount,
|
||||
percent: spamPercent,
|
||||
color: SPAM_COLOR,
|
||||
},
|
||||
].filter((slice) => slice.value > 0);
|
||||
|
||||
return (
|
||||
<div className="h-72 flex flex-col">
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full" style={{ background: INBOX_COLOR }} />
|
||||
<span className="text-slate-700">
|
||||
In Inbox <strong className="tabular-nums">{formatPercent(inboxPercent)}</strong>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 rounded-full" style={{ background: SPAM_COLOR }} />
|
||||
<span className="text-slate-700">
|
||||
In Spam <strong className="tabular-nums">{formatPercent(spamPercent)}</strong>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="58%"
|
||||
outerRadius="82%"
|
||||
paddingAngle={data.length > 1 ? 2 : 0}
|
||||
strokeWidth={2}
|
||||
stroke="#fff"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<PlacementTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-400 mt-1 tabular-nums">
|
||||
{total} warmup {total === 1 ? 'email' : 'emails'} tracked
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
LabelList,
|
||||
Legend,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
|
|
@ -10,7 +11,8 @@ import {
|
|||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { CampaignWarmupPlan, CampaignWarmupPlanDay } from '@warmbox/shared';
|
||||
import type { CampaignWarmupPlan } from '@warmbox/shared';
|
||||
import { formatChartDate } from '../lib/campaign-chart-utils';
|
||||
|
||||
type ChartRow = {
|
||||
dayIndex: number;
|
||||
|
|
@ -20,26 +22,41 @@ type ChartRow = {
|
|||
isFuture: boolean;
|
||||
plannedSends: number;
|
||||
plannedReplies: number;
|
||||
actualSends: number;
|
||||
actualReplies: number;
|
||||
spamDetected: number;
|
||||
inboxDelivered: number;
|
||||
spamDetected: number;
|
||||
scheduledRemainder: number;
|
||||
actualReplies: number;
|
||||
barTotal: number;
|
||||
};
|
||||
|
||||
function toChartRows(plan: CampaignWarmupPlan): ChartRow[] {
|
||||
return plan.days.map((day) => ({
|
||||
return plan.days.map((day) => {
|
||||
const inboxDelivered = day.isFuture
|
||||
? 0
|
||||
: Math.max(0, day.actual.sends - day.actual.spamDetected);
|
||||
const spamDetected = day.isFuture ? 0 : day.actual.spamDetected;
|
||||
const actualTotal = inboxDelivered + spamDetected;
|
||||
const scheduledRemainder = day.isFuture
|
||||
? day.planned.sends
|
||||
: Math.max(0, day.planned.sends - actualTotal);
|
||||
|
||||
const barTotal = day.isFuture ? day.planned.sends : Math.max(day.planned.sends, actualTotal);
|
||||
|
||||
return {
|
||||
dayIndex: day.dayIndex,
|
||||
label: `D${day.dayIndex}`,
|
||||
label: day.date ? formatChartDate(day.date) : `D${day.dayIndex}`,
|
||||
date: day.date,
|
||||
isToday: day.isToday,
|
||||
isFuture: day.isFuture,
|
||||
plannedSends: day.planned.sends,
|
||||
plannedReplies: day.planned.replies,
|
||||
actualSends: day.actual.sends,
|
||||
actualReplies: day.actual.replies,
|
||||
spamDetected: day.actual.spamDetected,
|
||||
inboxDelivered: Math.max(0, day.actual.sends - day.actual.spamDetected),
|
||||
}));
|
||||
inboxDelivered,
|
||||
spamDetected,
|
||||
scheduledRemainder,
|
||||
actualReplies: day.isFuture ? 0 : day.actual.replies,
|
||||
barTotal,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function PlanTooltip({
|
||||
|
|
@ -52,12 +69,13 @@ function PlanTooltip({
|
|||
if (!active || !payload?.length) return null;
|
||||
|
||||
const row = payload[0]!.payload;
|
||||
const sent = row.inboxDelivered + row.spamDetected;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[220px]">
|
||||
<p className="font-semibold text-slate-900">
|
||||
Day {row.dayIndex}
|
||||
{row.date ? ` · ${row.date}` : ''}
|
||||
{row.date ? ` · ${row.label}` : ''}
|
||||
</p>
|
||||
{row.isToday && <p className="text-xs text-indigo-600 font-medium mt-0.5">Today</p>}
|
||||
{row.isFuture && <p className="text-xs text-slate-400 mt-0.5">Upcoming</p>}
|
||||
|
|
@ -74,11 +92,11 @@ function PlanTooltip({
|
|||
<>
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-slate-500">Sent</dt>
|
||||
<dd className="font-medium tabular-nums text-indigo-600">{row.actualSends}</dd>
|
||||
<dd className="font-medium tabular-nums text-indigo-700">{sent}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-slate-500">Replied</dt>
|
||||
<dd className="font-medium tabular-nums text-emerald-600">{row.actualReplies}</dd>
|
||||
<dd className="font-medium tabular-nums text-pink-600">{row.actualReplies}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-slate-500">In spam</dt>
|
||||
|
|
@ -86,7 +104,7 @@ function PlanTooltip({
|
|||
</div>
|
||||
<div className="flex justify-between gap-6">
|
||||
<dt className="text-slate-500">Inbox</dt>
|
||||
<dd className="font-medium tabular-nums">{row.inboxDelivered}</dd>
|
||||
<dd className="font-medium tabular-nums text-emerald-700">{row.inboxDelivered}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -114,14 +132,14 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
|
|||
return (
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<ComposedChart data={data} margin={{ top: 20, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
interval={plan.durationDays > 20 ? Math.floor(plan.durationDays / 10) : 0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
|
||||
<Tooltip content={<PlanTooltip />} />
|
||||
<Legend />
|
||||
{todayRow && (
|
||||
|
|
@ -132,22 +150,45 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
|
|||
label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }}
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
dataKey="inboxDelivered"
|
||||
stackId="volume"
|
||||
fill="#1e3a5f"
|
||||
name="Sent"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="spamDetected"
|
||||
stackId="volume"
|
||||
fill="#b91c1c"
|
||||
name="In spam"
|
||||
radius={[0, 0, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="scheduledRemainder"
|
||||
stackId="volume"
|
||||
fill="#cbd5e1"
|
||||
name="Scheduled"
|
||||
radius={[4, 4, 0, 0]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="barTotal"
|
||||
position="top"
|
||||
className="fill-slate-600 text-[10px] font-medium"
|
||||
formatter={(value: number) => (value > 0 ? String(value) : '')}
|
||||
/>
|
||||
</Bar>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="plannedSends"
|
||||
stroke="#94a3b8"
|
||||
dataKey="actualReplies"
|
||||
name="Replies"
|
||||
stroke="#ec4899"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
name="Scheduled"
|
||||
dot={{ r: 4, fill: '#ec4899', strokeWidth: 0 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Bar dataKey="actualSends" fill="#4f46e5" name="Sent" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="actualReplies" fill="#10b981" name="Replied" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="spamDetected" fill="#ef4444" name="In spam" radius={[2, 2, 0, 0]} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { CampaignWarmupPlanDay };
|
||||
|
|
|
|||
41
apps/web/src/lib/campaign-chart-utils.ts
Normal file
41
apps/web/src/lib/campaign-chart-utils.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { CampaignStatsToday } from '@warmbox/shared';
|
||||
|
||||
export function formatChartDate(iso: string): string {
|
||||
const d = new Date(`${iso}T12:00:00`);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function last7DaysIsoRange(): { from: string; to: string } {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 6);
|
||||
const toIso = to.toISOString().slice(0, 10);
|
||||
const fromIso = from.toISOString().slice(0, 10);
|
||||
return { from: fromIso, to: toIso };
|
||||
}
|
||||
|
||||
export type DailyChartRow = {
|
||||
dayIndex: number;
|
||||
label: string;
|
||||
date: string;
|
||||
plannedSends: number;
|
||||
plannedReplies: number;
|
||||
sends: number;
|
||||
replies: number;
|
||||
spam: number;
|
||||
inbox: number;
|
||||
};
|
||||
|
||||
export function mapDailyStatToChartRow(stat: CampaignStatsToday): DailyChartRow {
|
||||
return {
|
||||
dayIndex: stat.dayIndex,
|
||||
label: formatChartDate(stat.date),
|
||||
date: stat.date,
|
||||
plannedSends: stat.planned.sends,
|
||||
plannedReplies: stat.planned.replies,
|
||||
sends: stat.actual.sends,
|
||||
replies: stat.actual.replies,
|
||||
spam: stat.actual.spamDetected,
|
||||
inbox: Math.max(0, stat.actual.sends - stat.actual.spamDetected),
|
||||
};
|
||||
}
|
||||
|
|
@ -97,6 +97,22 @@ export type Campaign = {
|
|||
|
||||
export type DailyStat = CampaignStatsToday;
|
||||
|
||||
export type CampaignStatsSummary = {
|
||||
campaignId: string;
|
||||
range: '7d' | '30d' | 'all';
|
||||
totals: {
|
||||
plannedSends: number;
|
||||
plannedReplies: number;
|
||||
sends: number;
|
||||
replies: number;
|
||||
spamDetected: number;
|
||||
spamRescued: number;
|
||||
};
|
||||
rates: { reply: number; inbox: number };
|
||||
onTrack: boolean;
|
||||
days: number;
|
||||
};
|
||||
|
||||
export type WarmupLog = {
|
||||
id: string;
|
||||
campaignId: string | null;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { invalidateCampaignCaches, invalidateCampaignListCaches } from '../lib/q
|
|||
import type {
|
||||
Campaign,
|
||||
CampaignSatellite,
|
||||
CampaignStatsSummary,
|
||||
CampaignStatsToday,
|
||||
CampaignWarmupPlan,
|
||||
ComplianceReport,
|
||||
|
|
@ -18,6 +19,10 @@ import { StatusBadge } from '../components/StatusBadge';
|
|||
import { MetricCard } from '../components/MetricCard';
|
||||
import { ComplianceChecklist } from '../components/ComplianceChecklist';
|
||||
import { WarmupPlanChart } from '../components/WarmupPlanChart';
|
||||
import { InboxVsSpamChart } from '../components/InboxVsSpamChart';
|
||||
import { DailyPerformanceChart } from '../components/DailyPerformanceChart';
|
||||
import { InboxPlacementChart } from '../components/InboxPlacementChart';
|
||||
import { last7DaysIsoRange, mapDailyStatToChartRow } from '../lib/campaign-chart-utils';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
|
||||
import { Skeleton } from '../components/ui/Skeleton';
|
||||
|
|
@ -78,6 +83,31 @@ export function CampaignDetailPage() {
|
|||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: daily7d } = useQuery({
|
||||
queryKey: ['campaign', id, 'stats', 'daily', '7d'],
|
||||
queryFn: () => {
|
||||
const { from, to } = last7DaysIsoRange();
|
||||
return apiGet<{ data: CampaignStatsToday[] }>(
|
||||
`/api/campaigns/${id}/stats/daily?from=${from}&to=${to}`,
|
||||
);
|
||||
},
|
||||
enabled: !!id,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: placementSummary } = useQuery({
|
||||
queryKey: ['campaign', id, 'stats', 'all'],
|
||||
queryFn: () => apiGet<CampaignStatsSummary>(`/api/campaigns/${id}/stats?range=all`),
|
||||
enabled: !!id,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const dailyChartData = daily7d?.data.map(mapDailyStatToChartRow) ?? [];
|
||||
const inboxCount = placementSummary
|
||||
? Math.max(0, placementSummary.totals.sends - placementSummary.totals.spamDetected)
|
||||
: 0;
|
||||
const spamCount = placementSummary?.totals.spamDetected ?? 0;
|
||||
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ['logs', { campaignId: id }],
|
||||
queryFn: () =>
|
||||
|
|
@ -314,12 +344,13 @@ export function CampaignDetailPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Warmup Plan</CardTitle>
|
||||
<CardTitle>Inbox Warming Plan</CardTitle>
|
||||
<p className="text-sm text-slate-500 font-normal mt-1">
|
||||
Full {campaign.durationDays}-day schedule with live progress. Hover a day for
|
||||
scheduled vs sent, replied, and spam counts. Updates every 30 seconds.
|
||||
Full {campaign.durationDays}-day schedule — scheduled volume, live sends, spam,
|
||||
and replies. Hover any day for details. Updates every 30 seconds.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -338,6 +369,47 @@ export function CampaignDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Inbox vs Spam</CardTitle>
|
||||
<p className="text-sm text-slate-500 font-normal mt-1">
|
||||
Where warmup emails landed in the primary mailbox for this campaign.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InboxVsSpamChart inboxCount={inboxCount} spamCount={spamCount} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{campaign.currentDay > 0 && (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Performance</CardTitle>
|
||||
<p className="text-sm text-slate-500 font-normal mt-1">
|
||||
Last 7 days — scheduled vs sent vs replied volume.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DailyPerformanceChart data={dailyChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Inbox Placement (7d)</CardTitle>
|
||||
<p className="text-sm text-slate-500 font-normal mt-1">
|
||||
Last 7 days — inbox vs spam count per day.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InboxPlacementChart data={dailyChartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Details</CardTitle>
|
||||
|
|
|
|||
183
docs/deploy-ci-cd.md
Normal file
183
docs/deploy-ci-cd.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# CI/CD with Woodpecker (Forgejo)
|
||||
|
||||
Automate deploys to your home server when you push to **`main`** on [Forgejo](https://git.jonnyn.com/).
|
||||
|
||||
```text
|
||||
git push main → Forgejo webhook → Woodpecker → verify (build/test) → deploy.sh → docker compose
|
||||
```
|
||||
|
||||
Woodpecker is the right choice here — it runs on your server next to the app, can use the Docker socket, and integrates natively with Forgejo. GitHub Actions would require SSH secrets and extra wiring for a self-hosted Forgejo repo.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Piece | Role |
|
||||
|-------|------|
|
||||
| **Forgejo** (`git.jonnyn.com`) | Git remote, sends webhooks on push |
|
||||
| **Woodpecker** | Runs `.woodpecker.yml` pipeline |
|
||||
| **`scripts/deploy.sh`** | `git pull` + `docker compose down` + `up --build` on server |
|
||||
| **`/srv/docker-configs/warmbox`** | Live deploy directory (must stay a git clone) |
|
||||
|
||||
---
|
||||
|
||||
## 1. One-time server setup
|
||||
|
||||
### Deploy directory is a git clone
|
||||
|
||||
Your live app should already be:
|
||||
|
||||
```bash
|
||||
cd /srv/docker-configs/warmbox
|
||||
git remote -v
|
||||
# origin https://git.jonnyn.com/you/warmbox.git (or SSH URL)
|
||||
```
|
||||
|
||||
If you copied files without git, re-clone:
|
||||
|
||||
```bash
|
||||
mv /srv/docker-configs/warmbox /srv/docker-configs/warmbox.bak
|
||||
git clone https://git.jonnyn.com/YOU/warmbox.git /srv/docker-configs/warmbox
|
||||
cp /srv/docker-configs/warmbox.bak/.env /srv/docker-configs/warmbox/
|
||||
# restore warmbox-data volume is unchanged — docker volume persists
|
||||
```
|
||||
|
||||
Ensure `git pull` works without a password (deploy key or credential helper):
|
||||
|
||||
```bash
|
||||
cd /srv/docker-configs/warmbox
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
### Make deploy script executable
|
||||
|
||||
```bash
|
||||
chmod +x /srv/docker-configs/warmbox/scripts/deploy.sh
|
||||
```
|
||||
|
||||
### Woodpecker agent: allow Docker socket + deploy path
|
||||
|
||||
The **deploy** step mounts:
|
||||
|
||||
- `/var/run/docker.sock` — run `docker compose` from CI
|
||||
- `/srv/docker-configs/warmbox` — your live checkout
|
||||
|
||||
In your **Woodpecker agent** container environment (docker-compose or stack config), allow pipeline volumes. Example:
|
||||
|
||||
```yaml
|
||||
# woodpecker-agent service (adjust to your stack)
|
||||
environment:
|
||||
WOODPECKER_VOLUMES_ALLOWED: /var/run/docker.sock,/srv/docker-configs/warmbox
|
||||
```
|
||||
|
||||
Alternatively, in the Woodpecker UI → your **warmbox** repository → enable **Trusted** (allows volume mounts for that repo).
|
||||
|
||||
The agent also needs the Docker socket mounted (you likely already have this):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
If your deploy path differs from `/srv/docker-configs/warmbox`, edit:
|
||||
|
||||
- `.woodpecker.yml` → `deploy` step volume path
|
||||
- Or set a symlink
|
||||
|
||||
---
|
||||
|
||||
## 2. Connect Forgejo to Woodpecker
|
||||
|
||||
These steps are in the Woodpecker UI (URL depends on your install, often `https://ci.jonnyn.com` or similar).
|
||||
|
||||
1. **Forgejo OAuth app** (Forgejo → Settings → Applications → Create OAuth2 Application)
|
||||
- Redirect URI: `https://<woodpecker-host>/authorize`
|
||||
2. **Woodpecker server** — add Forgejo host `https://git.jonnyn.com` with OAuth client ID/secret
|
||||
3. **Login to Woodpecker** with Forgejo account
|
||||
4. **Activate** the `warmbox` repository
|
||||
5. Enable **Trusted** on the repo (if not using `WOODPECKER_VOLUMES_ALLOWED`)
|
||||
6. Confirm webhook exists on Forgejo (repo → Settings → Webhooks)
|
||||
|
||||
---
|
||||
|
||||
## 3. Pipeline file (in repo)
|
||||
|
||||
Already included at repo root:
|
||||
|
||||
**`.woodpecker.yml`**
|
||||
|
||||
1. **`verify`** — `npm ci`, `prisma generate`, `build`, `test` (runs in Woodpecker workspace clone)
|
||||
2. **`deploy`** — runs `scripts/deploy.sh` in `/srv/docker-configs/warmbox`
|
||||
|
||||
Push this file to `main`, then Woodpecker picks it up automatically.
|
||||
|
||||
---
|
||||
|
||||
## 4. First CI deploy
|
||||
|
||||
```bash
|
||||
# On your Mac
|
||||
git add .woodpecker.yml scripts/deploy.sh docs/deploy-ci-cd.md
|
||||
git commit -m "Add Woodpecker CI/CD deploy pipeline"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Watch the pipeline in Woodpecker UI. On success:
|
||||
|
||||
```bash
|
||||
curl -s https://warmbox.jonnyn.com/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual deploy (without CI)
|
||||
|
||||
```bash
|
||||
ssh burke
|
||||
cd /srv/docker-configs/warmbox
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Customize deploy path
|
||||
|
||||
If not using `/srv/docker-configs/warmbox`, edit `.woodpecker.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /your/path/warmbox:/deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| Pipeline not triggered | Check Forgejo webhook deliveries; repo activated in Woodpecker |
|
||||
| `volume not allowed` | Trusted repo or `WOODPECKER_VOLUMES_ALLOWED` on agent |
|
||||
| `permission denied` docker.sock | Agent container must mount `/var/run/docker.sock` |
|
||||
| `git fetch` fails in deploy | Fix git credentials in `/srv/docker-configs/warmbox` |
|
||||
| `.env missing` | Create `.env` on server (never commit it); CI does not copy `.env` |
|
||||
| `verify` fails | Fix tests locally; push again |
|
||||
| `deploy` health check fails | `docker compose logs warmbox` on server |
|
||||
| Wrong branch | Default is `main`; set `DEPLOY_BRANCH` in deploy step if needed |
|
||||
|
||||
### Brief downtime
|
||||
|
||||
`docker compose down` stops Warmbox for the length of the rebuild (usually 1–3 minutes). Cron jobs pause during that window — acceptable for homelab deploys.
|
||||
|
||||
### Secrets
|
||||
|
||||
- **Do not** commit `.env` — stays only on server
|
||||
- Woodpecker does not need `ENCRYPTION_KEY` or `API_KEY` for deploy (only `verify` uses a dummy key)
|
||||
|
||||
---
|
||||
|
||||
## Optional: deploy only (skip tests)
|
||||
|
||||
For faster iteration, comment out the `verify` step and remove `depends_on` from `deploy` in `.woodpecker.yml`. Not recommended for `main` long term.
|
||||
|
||||
See also [deploy-ubuntu-docker.md](./deploy-ubuntu-docker.md).
|
||||
|
|
@ -221,3 +221,9 @@ Common causes: missing `ENCRYPTION_KEY`, bad `DATABASE_URL`, migration error.
|
|||
NPM and Warmbox must share a Docker network so NPM can resolve the hostname `warmbox`. A separate `warmbox_internal` network is unnecessary for this setup — one external network is enough.
|
||||
|
||||
See also [self-host-hardening.md](./self-host-hardening.md).
|
||||
|
||||
---
|
||||
|
||||
## CI/CD (Woodpecker)
|
||||
|
||||
Push to `main` on Forgejo → automatic deploy. See **[deploy-ci-cd.md](./deploy-ci-cd.md)**.
|
||||
|
|
|
|||
48
scripts/deploy.sh
Executable file
48
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
# Production deploy — run on the server in the git checkout (e.g. /srv/docker-configs/warmbox).
|
||||
# Used by Woodpecker CI and manual deploys.
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
BRANCH="${DEPLOY_BRANCH:-main}"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "==> Deploying Warmbox from ${DEPLOY_DIR} (branch ${BRANCH})"
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "ERROR: ${DEPLOY_DIR} is not a git repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin "$BRANCH"
|
||||
git reset --hard "origin/${BRANCH}"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "ERROR: .env missing in ${DEPLOY_DIR}. Create it from .env.example before deploying."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Stopping containers..."
|
||||
docker compose down
|
||||
|
||||
echo "==> Building and starting..."
|
||||
docker compose up -d --build
|
||||
|
||||
echo "==> Waiting for container health..."
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T warmbox node -e \
|
||||
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "==> Health check passed"
|
||||
docker compose ps
|
||||
echo "==> Deploy complete"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Health check failed"
|
||||
docker compose ps
|
||||
docker compose logs --tail=80 warmbox
|
||||
exit 1
|
||||
Loading…
Reference in a new issue