Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions frontend/src/components/common/StatsCharts/StatsCharts.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.chartCard {
margin-top: 20px;
overflow: hidden;
}

.chartCardTitle {
display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 20px;

h2 {
margin: 0;
}
}

.dateRange {
color: var(--mantine-color-gray-5);
font-size: 0.875rem;
white-space: nowrap;
}

.chart {
width: 100%;
height: 300px;
}

@media (max-width: 768px) {
.chartCardTitle {
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin-bottom: 12px;
}
}
87 changes: 87 additions & 0 deletions frontend/src/components/common/StatsCharts/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {ReactNode} from "react";
import {AreaChart} from "@mantine/charts";
import {t} from "@lingui/macro";
import {Card} from "../Card";
import {EventDailyStats} from "../../../types.ts";
import {formatCurrency} from "../../../utilites/currency.ts";
import {formatDateWithLocale} from "../../../utilites/dates.ts";
import classes from "./StatsCharts.module.scss";

interface StatsChartCardProps {
dailyStats: EventDailyStats[] | undefined;
timezone: string;
dateRangeLabel: string;
syncId: string;
}

const responsiveChartProps = {
className: classes.chart,
dataKey: "date",
withLegend: true,
legendProps: {verticalAlign: 'bottom' as const},
styles: {legend: {justifyContent: 'center', rowGap: 4}},
xAxisProps: {minTickGap: 24},
yAxisProps: {width: 'auto' as const},
tickLine: "none" as const,
};

const ChartCard = ({title, dateRangeLabel, children}: {
title: string;
dateRangeLabel: string;
children: ReactNode;
}) => (
<Card className={classes.chartCard}>
<div className={classes.chartCardTitle}>
<h2>{title}</h2>
{dateRangeLabel && <span className={classes.dateRange}>{dateRangeLabel}</span>}
</div>
{children}
</Card>
);

export const ProductSalesChartCard = ({dailyStats, timezone, dateRangeLabel, syncId}: StatsChartCardProps) => (
<ChartCard title={t`Product Sales`} dateRangeLabel={dateRangeLabel}>
<AreaChart
{...responsiveChartProps}
data={dailyStats?.map(stat => ({
date: formatDateWithLocale(stat.date, 'chartDate', timezone),
orders_created: stat.orders_created,
products_sold: stat.products_sold,
attendees_registered: stat.attendees_registered,
})) || []}
series={[
{name: 'orders_created', color: 'blue.6', label: t`Completed Orders`},
{name: 'products_sold', color: 'blue.2', label: t`Products Sold`},
{name: 'attendees_registered', color: 'blue.4', label: t`Attendees Registered`},
]}
curveType="bump"
areaChartProps={{syncId}}
/>
</ChartCard>
);

export const RevenueChartCard = ({dailyStats, timezone, dateRangeLabel, syncId, currency}: StatsChartCardProps & {
currency: string;
}) => (
<ChartCard title={t`Revenue`} dateRangeLabel={dateRangeLabel}>
<AreaChart
{...responsiveChartProps}
data={dailyStats?.map(stat => ({
date: formatDateWithLocale(stat.date, 'chartDate', timezone),
total_fees: stat.total_fees,
total_sales_gross: stat.total_sales_gross,
total_tax: stat.total_tax,
total_refunded: stat.total_refunded,
})) || []}
valueFormatter={(value) => formatCurrency(value, currency)}
series={[
{name: 'total_fees', label: t`Total Fees`, color: 'primary.3'},
{name: 'total_sales_gross', label: t`Gross Sales`, color: 'grape.5'},
{name: 'total_tax', label: t`Total Tax`, color: 'grape.7'},
{name: 'total_refunded', label: t`Total Refunded`, color: 'red.6'},
]}
curveType="natural"
areaChartProps={{syncId}}
/>
</ChartCard>
);
72 changes: 72 additions & 0 deletions misc/k6/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# k6 load tests

Two scripts exercising the public ticket-buying API. Both default to the local dev stack
(`https://localhost:8443/api`, self-signed certs are skipped unless `INSECURE=false`).

| Script | What it does |
|---|---|
| `checkout-flow.js` | Full buyer funnel per iteration: event page → reserve (`POST /order`) → complete (free) or abandon → order summary (`GET /order?include=event`). Counts reservations, completions, sold-out rejections, 429s, 5xx, and per-step p95. Can create a capacity-capped product and assert nothing oversells. |
| `event-page.js` | Read-side only: hammers `GET /public/events/{id}` (optionally with `event_occurrence_id`, which bypasses the 2s quantity cache). |

## Setup

```bash
cd docker/development
docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap
```

That prints `single_event_id`, `free_product_id / price_id`, `recurring_event_id`, occurrence ids and a Bearer token.

The dev stack's `backend/.env` sets `APP_API_RATE_LIMIT_PER_MINUTE=100000`. Against any other environment, k6
runs from one IP and the default `api` throttle (180/min per IP) will 429 you almost immediately — either raise
that env var on the target or expect the `rate_limited` threshold to fail (which is itself a finding).

## Checkout funnel

Smoke (3 iterations, 1 VU) to prove the target is wired correctly:

```bash
EVENT_ID=123 SCENARIO=smoke k6 run misc/k6/checkout-flow.js
```

Spike — ramp to `RATE` new buyers/second over 20s, hold for `DURATION`, ramp down — against a freshly created
product capped at `CAPACITY` tickets (needs `TOKEN` so setup can create it). This is the oversell test: when the
run ends, `tickets_completed` must be ≤ `CAPACITY`, the teardown re-reads the product with the token and checks
`quantity_sold <= initial_quantity_available`, and the remaining buyers must have received sold-out 422s rather
than 5xx or timeouts.

```bash
EVENT_ID=123 TOKEN=... CAPACITY=200 RATE=40 DURATION=1m k6 run misc/k6/checkout-flow.js
```

Steady state at a fixed arrival rate against an existing product (auto-discovers the first in-stock FREE product,
or pin one with `PRODUCT_ID`/`PRICE_ID`; recurring events pick the first active occurrence or `OCCURRENCE_ID`):

```bash
EVENT_ID=123 SCENARIO=steady RATE=20 DURATION=2m k6 run misc/k6/checkout-flow.js
```

Variables: `BASE_URL`, `EVENT_ID` (required), `SCENARIO` (`smoke|steady|spike`, default `spike`), `RATE`
(arrivals/sec, default 30), `DURATION` (default `1m`), `MAX_QTY` (tickets per order, 1..N, default 2),
`ABANDON_RATIO` (share of buyers who abandon after reserving, default 0.1), `PRODUCT_ID`/`PRICE_ID`,
`OCCURRENCE_ID`, `TOKEN` + `CAPACITY` (create a capped product), `INSECURE` (default true).

Thresholds fail the run on any 429, any 5xx, any unexpected status, or p95 above 800ms (reads) / 1500ms
(reserve, complete). Expected sold-out 422s are counted in `sold_out_responses` and do not fail the run.

## Event page

```bash
EVENT_ID=123 RATE=100 DURATION=30s k6 run misc/k6/event-page.js
EVENT_ID=456 OCCURRENCE_ID=789 RATE=100 k6 run misc/k6/event-page.js # cache-bypassing recurring path
```

## Reading results

- `step_reserve` p95 climbing with `RATE` while `step_event_page` stays flat → the per-event advisory lock in
`CreateOrderHandler` is the bottleneck (all reservations for one event serialise behind it).
- `rate_limited > 0` → the per-IP `api` throttle is biting before the database does.
- `tickets_completed > CAPACITY` or the teardown oversell check failing → a capacity race (shared capacity
assignments are the known weak spot; per-price/per-occurrence limits are enforced under the lock).
- Completed-order emails and event statistics are queued; watch the queue worker and Mailpit
(`http://localhost:8025`) to see how far delivery lags behind completion.
Loading
Loading