diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..c2b3a8b --- /dev/null +++ b/.woodpecker.yml @@ -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 diff --git a/apps/web/src/components/DailyPerformanceChart.tsx b/apps/web/src/components/DailyPerformanceChart.tsx new file mode 100644 index 0000000..b14976d --- /dev/null +++ b/apps/web/src/components/DailyPerformanceChart.tsx @@ -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 ( +
+

{row.label}

+

Day {row.dayIndex}

+
+
+
Scheduled
+
{row.plannedSends}
+
+
+
Sent
+
{row.sends}
+
+
+
Replied
+
{row.replies}
+
+
+
+ ); +} + +type DailyPerformanceChartProps = { + data: DailyChartRow[]; +}; + +export function DailyPerformanceChart({ data }: DailyPerformanceChartProps) { + if (data.length === 0) { + return ( +

+ No activity in the last 7 days yet. +

+ ); + } + + return ( +
+ + + + + + } /> + + + + + + +
+ ); +} diff --git a/apps/web/src/components/InboxPlacementChart.tsx b/apps/web/src/components/InboxPlacementChart.tsx new file mode 100644 index 0000000..534e99a --- /dev/null +++ b/apps/web/src/components/InboxPlacementChart.tsx @@ -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 ( +
+

{row.label}

+

Day {row.dayIndex}

+
+
+
Inbox
+
{row.inbox}
+
+
+
Spam
+
{row.spam}
+
+
+
Inbox rate
+
{formatPercent(inboxRate)}
+
+
+
+ ); +} + +type InboxPlacementChartProps = { + data: DailyChartRow[]; +}; + +export function InboxPlacementChart({ data }: InboxPlacementChartProps) { + if (data.length === 0) { + return ( +

+ No placement data in the last 7 days yet. +

+ ); + } + + return ( +
+ + + + + + } /> + + + + + +
+ ); +} diff --git a/apps/web/src/components/InboxVsSpamChart.tsx b/apps/web/src/components/InboxVsSpamChart.tsx new file mode 100644 index 0000000..1747c57 --- /dev/null +++ b/apps/web/src/components/InboxVsSpamChart.tsx @@ -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 ( +
+

+ {slice.name} +

+

+ {slice.value} emails · {formatPercent(slice.percent)} +

+
+ ); +} + +export function InboxVsSpamChart({ inboxCount, spamCount }: InboxVsSpamChartProps) { + const total = inboxCount + spamCount; + + if (total === 0) { + return ( +

+ No warmup emails received yet — placement appears after the first sends land in the + primary inbox. +

+ ); + } + + 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 ( +
+
+ + + + In Inbox {formatPercent(inboxPercent)} + + + + + + In Spam {formatPercent(spamPercent)} + + +
+
+ + + 1 ? 2 : 0} + strokeWidth={2} + stroke="#fff" + > + {data.map((entry) => ( + + ))} + + } /> + + +
+

+ {total} warmup {total === 1 ? 'email' : 'emails'} tracked +

+
+ ); +} diff --git a/apps/web/src/components/WarmupPlanChart.tsx b/apps/web/src/components/WarmupPlanChart.tsx index 21e0b55..469a769 100644 --- a/apps/web/src/components/WarmupPlanChart.tsx +++ b/apps/web/src/components/WarmupPlanChart.tsx @@ -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) => ({ - dayIndex: day.dayIndex, - label: `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), - })); + 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: 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, + 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 (

Day {row.dayIndex} - {row.date ? ` · ${row.date}` : ''} + {row.date ? ` · ${row.label}` : ''}

{row.isToday &&

Today

} {row.isFuture &&

Upcoming

} @@ -74,11 +92,11 @@ function PlanTooltip({ <>
Sent
-
{row.actualSends}
+
{sent}
Replied
-
{row.actualReplies}
+
{row.actualReplies}
In spam
@@ -86,7 +104,7 @@ function PlanTooltip({
Inbox
-
{row.inboxDelivered}
+
{row.inboxDelivered}
)} @@ -114,14 +132,14 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) { return (
- + 20 ? Math.floor(plan.durationDays / 10) : 0} /> - + } /> {todayRow && ( @@ -132,22 +150,45 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) { label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }} /> )} + + + + (value > 0 ? String(value) : '')} + /> + - - -
); } - -export type { CampaignWarmupPlanDay }; diff --git a/apps/web/src/lib/campaign-chart-utils.ts b/apps/web/src/lib/campaign-chart-utils.ts new file mode 100644 index 0000000..cd2da07 --- /dev/null +++ b/apps/web/src/lib/campaign-chart-utils.ts @@ -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), + }; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 6663934..7c28da0 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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; diff --git a/apps/web/src/pages/CampaignDetailPage.tsx b/apps/web/src/pages/CampaignDetailPage.tsx index 755229d..7960e95 100644 --- a/apps/web/src/pages/CampaignDetailPage.tsx +++ b/apps/web/src/pages/CampaignDetailPage.tsx @@ -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(`/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,29 +344,71 @@ export function CampaignDetailPage() { />
- - - Warmup Plan -

- Full {campaign.durationDays}-day schedule with live progress. Hover a day for - scheduled vs sent, replied, and spam counts. Updates every 30 seconds. -

-
- - {warmupPlan ? ( - - ) : campaign.currentDay === 0 ? ( -

- Start the campaign to see the warmup plan chart. +

+ + + Inbox Warming Plan +

+ Full {campaign.durationDays}-day schedule — scheduled volume, live sends, spam, + and replies. Hover any day for details. Updates every 30 seconds.

- ) : ( -
- - Loading warmup plan… -
- )} - -
+ + + {warmupPlan ? ( + + ) : campaign.currentDay === 0 ? ( +

+ Start the campaign to see the warmup plan chart. +

+ ) : ( +
+ + Loading warmup plan… +
+ )} +
+ + + + + Inbox vs Spam +

+ Where warmup emails landed in the primary mailbox for this campaign. +

+
+ + + +
+
+ + {campaign.currentDay > 0 && ( +
+ + + Daily Performance +

+ Last 7 days — scheduled vs sent vs replied volume. +

+
+ + + +
+ + + + Inbox Placement (7d) +

+ Last 7 days — inbox vs spam count per day. +

+
+ + + +
+
+ )} diff --git a/docs/deploy-ci-cd.md b/docs/deploy-ci-cd.md new file mode 100644 index 0000000..8311ce8 --- /dev/null +++ b/docs/deploy-ci-cd.md @@ -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:///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). diff --git a/docs/deploy-ubuntu-docker.md b/docs/deploy-ubuntu-docker.md index 3e6ceec..227bbd6 100644 --- a/docs/deploy-ubuntu-docker.md +++ b/docs/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)**. diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..c7e7006 --- /dev/null +++ b/scripts/deploy.sh @@ -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