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,
|
Bar,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
|
LabelList,
|
||||||
Legend,
|
Legend,
|
||||||
Line,
|
Line,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
|
|
@ -10,7 +11,8 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { CampaignWarmupPlan, CampaignWarmupPlanDay } from '@warmbox/shared';
|
import type { CampaignWarmupPlan } from '@warmbox/shared';
|
||||||
|
import { formatChartDate } from '../lib/campaign-chart-utils';
|
||||||
|
|
||||||
type ChartRow = {
|
type ChartRow = {
|
||||||
dayIndex: number;
|
dayIndex: number;
|
||||||
|
|
@ -20,26 +22,41 @@ type ChartRow = {
|
||||||
isFuture: boolean;
|
isFuture: boolean;
|
||||||
plannedSends: number;
|
plannedSends: number;
|
||||||
plannedReplies: number;
|
plannedReplies: number;
|
||||||
actualSends: number;
|
|
||||||
actualReplies: number;
|
|
||||||
spamDetected: number;
|
|
||||||
inboxDelivered: number;
|
inboxDelivered: number;
|
||||||
|
spamDetected: number;
|
||||||
|
scheduledRemainder: number;
|
||||||
|
actualReplies: number;
|
||||||
|
barTotal: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toChartRows(plan: CampaignWarmupPlan): ChartRow[] {
|
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,
|
dayIndex: day.dayIndex,
|
||||||
label: `D${day.dayIndex}`,
|
label: day.date ? formatChartDate(day.date) : `D${day.dayIndex}`,
|
||||||
date: day.date,
|
date: day.date,
|
||||||
isToday: day.isToday,
|
isToday: day.isToday,
|
||||||
isFuture: day.isFuture,
|
isFuture: day.isFuture,
|
||||||
plannedSends: day.planned.sends,
|
plannedSends: day.planned.sends,
|
||||||
plannedReplies: day.planned.replies,
|
plannedReplies: day.planned.replies,
|
||||||
actualSends: day.actual.sends,
|
inboxDelivered,
|
||||||
actualReplies: day.actual.replies,
|
spamDetected,
|
||||||
spamDetected: day.actual.spamDetected,
|
scheduledRemainder,
|
||||||
inboxDelivered: Math.max(0, day.actual.sends - day.actual.spamDetected),
|
actualReplies: day.isFuture ? 0 : day.actual.replies,
|
||||||
}));
|
barTotal,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlanTooltip({
|
function PlanTooltip({
|
||||||
|
|
@ -52,12 +69,13 @@ function PlanTooltip({
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
const row = payload[0]!.payload;
|
const row = payload[0]!.payload;
|
||||||
|
const sent = row.inboxDelivered + row.spamDetected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[220px]">
|
<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">
|
<p className="font-semibold text-slate-900">
|
||||||
Day {row.dayIndex}
|
Day {row.dayIndex}
|
||||||
{row.date ? ` · ${row.date}` : ''}
|
{row.date ? ` · ${row.label}` : ''}
|
||||||
</p>
|
</p>
|
||||||
{row.isToday && <p className="text-xs text-indigo-600 font-medium mt-0.5">Today</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>}
|
{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">
|
<div className="flex justify-between gap-6">
|
||||||
<dt className="text-slate-500">Sent</dt>
|
<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>
|
||||||
<div className="flex justify-between gap-6">
|
<div className="flex justify-between gap-6">
|
||||||
<dt className="text-slate-500">Replied</dt>
|
<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>
|
||||||
<div className="flex justify-between gap-6">
|
<div className="flex justify-between gap-6">
|
||||||
<dt className="text-slate-500">In spam</dt>
|
<dt className="text-slate-500">In spam</dt>
|
||||||
|
|
@ -86,7 +104,7 @@ function PlanTooltip({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-6">
|
<div className="flex justify-between gap-6">
|
||||||
<dt className="text-slate-500">Inbox</dt>
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -114,14 +132,14 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
|
||||||
return (
|
return (
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11 }}
|
||||||
interval={plan.durationDays > 20 ? Math.floor(plan.durationDays / 10) : 0}
|
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 />} />
|
<Tooltip content={<PlanTooltip />} />
|
||||||
<Legend />
|
<Legend />
|
||||||
{todayRow && (
|
{todayRow && (
|
||||||
|
|
@ -132,22 +150,45 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
|
||||||
label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }}
|
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
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="plannedSends"
|
dataKey="actualReplies"
|
||||||
stroke="#94a3b8"
|
name="Replies"
|
||||||
|
stroke="#ec4899"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="5 5"
|
dot={{ r: 4, fill: '#ec4899', strokeWidth: 0 }}
|
||||||
dot={false}
|
activeDot={{ r: 6 }}
|
||||||
name="Scheduled"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</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 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 = {
|
export type WarmupLog = {
|
||||||
id: string;
|
id: string;
|
||||||
campaignId: string | null;
|
campaignId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { invalidateCampaignCaches, invalidateCampaignListCaches } from '../lib/q
|
||||||
import type {
|
import type {
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignSatellite,
|
CampaignSatellite,
|
||||||
|
CampaignStatsSummary,
|
||||||
CampaignStatsToday,
|
CampaignStatsToday,
|
||||||
CampaignWarmupPlan,
|
CampaignWarmupPlan,
|
||||||
ComplianceReport,
|
ComplianceReport,
|
||||||
|
|
@ -18,6 +19,10 @@ import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { MetricCard } from '../components/MetricCard';
|
import { MetricCard } from '../components/MetricCard';
|
||||||
import { ComplianceChecklist } from '../components/ComplianceChecklist';
|
import { ComplianceChecklist } from '../components/ComplianceChecklist';
|
||||||
import { WarmupPlanChart } from '../components/WarmupPlanChart';
|
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
|
||||||
import { Skeleton } from '../components/ui/Skeleton';
|
import { Skeleton } from '../components/ui/Skeleton';
|
||||||
|
|
@ -78,6 +83,31 @@ export function CampaignDetailPage() {
|
||||||
refetchInterval: 30_000,
|
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({
|
const { data: logs } = useQuery({
|
||||||
queryKey: ['logs', { campaignId: id }],
|
queryKey: ['logs', { campaignId: id }],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
|
|
@ -314,12 +344,13 @@ export function CampaignDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Warmup Plan</CardTitle>
|
<CardTitle>Inbox Warming Plan</CardTitle>
|
||||||
<p className="text-sm text-slate-500 font-normal mt-1">
|
<p className="text-sm text-slate-500 font-normal mt-1">
|
||||||
Full {campaign.durationDays}-day schedule with live progress. Hover a day for
|
Full {campaign.durationDays}-day schedule — scheduled volume, live sends, spam,
|
||||||
scheduled vs sent, replied, and spam counts. Updates every 30 seconds.
|
and replies. Hover any day for details. Updates every 30 seconds.
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -338,6 +369,47 @@ export function CampaignDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Campaign Details</CardTitle>
|
<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.
|
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).
|
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