Really huge mass update; Getting everything up-to-spec and implementing a wide range of features
Deploy / Build (pull_request) Successful in 40s
Deploy / Build and Push Docker Image (pull_request) Has been skipped

This commit is contained in:
2026-07-26 14:24:18 +02:00
parent 2c563685bd
commit 8b53698f29
72 changed files with 4275 additions and 693 deletions
+87
View File
@@ -0,0 +1,87 @@
name: Deploy
on:
push:
branches: ["main", "dev"]
pull_request:
branches: ["main", "dev"]
permissions:
contents: read
packages: write
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm --filter pp-backend run generate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/pp
- name: Build backend
run: pnpm --filter pp-backend run build
- name: Build frontend
run: pnpm --filter pp-frontend run build
docker:
name: Build and Push Docker Image
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: registry.reversed.dev/pp-previews/core
tags: |
type=raw,value=prod,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
type=raw,value=dev,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }}
type=sha,format=long,enable=${{ github.event_name == 'push' }}
labels: |
org.opencontainers.image.title=PR Previews
org.opencontainers.image.description=Self-hosted Gitea PR preview environments on EC2
org.opencontainers.image.vendor=space
- name: Log in to Harbor
uses: docker/login-action@v3
with:
registry: registry.reversed.dev
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_PASSWORD }}
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: false
sbom: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+273
View File
@@ -0,0 +1,273 @@
# CLAUDE.md
Guidance for AI agents (and humans) working in this repository. Read this before making changes.
> **This file is mirrored 1:1 to `AGENTS.md`.** If you edit one, make the identical edit to the other. They must stay byte-for-byte identical.
---
## What this project is
**PR Previews (PP)** is a self-hosted service that connects to a **Gitea** instance via webhook. When a PR is opened or updated, PP:
1. Provisions an **AWS EC2** instance in the user's own AWS account,
2. Clones the PR branch, installs deps, builds, and runs the app over SSH,
3. Posts a **live preview URL** (`http://<ec2-ip>:<port>`) back onto the PR as an in-place-edited comment.
It is built for teams and agent-driven workflows where reviewers want to see a running PR without pulling code locally. **Gitea only** — no GitHub/GitLab. One preview per PR; the EC2 instance is reused across pushes.
The canonical product/behavior spec is [`SPEC.md`](./SPEC.md). When behavior is ambiguous, **SPEC.md wins** — this file describes *how the code is built*, SPEC.md describes *what it must do*.
---
## Repository layout
This is a **pnpm workspace monorepo** (`pnpm-workspace.yaml``backend`, `frontend`).
```
./backend/ # Node.js API + workers (rjweb-server + Prisma). Compiled with esbuild.
./frontend/ # React 19 + Vite + Tailwind SPA.
./prisma/ # Prisma schema + migrations — LIVES AT REPO ROOT, not in backend/.
schema.prisma
migrations/
./Dockerfile # Multi-stage build for the whole app (frontend + backend + prisma).
./docker-compose.yml # Runs PP itself: pp-backend + pp-db (Postgres). NOT the preview EC2s.
./SPEC.md # Product/behavior specification (source of truth for behavior).
./example.env # Env var template.
./TODO.md # Known follow-ups / rough edges (untracked scratch list).
```
### Backend source map (`backend/src/`)
```
index.ts # Server bootstrap: builds rjweb Server, registers ALL routes, starts workers.
lib/
env.ts # Zod-validated process.env. Loads backend/.env. Throws on invalid env at boot.
db.ts # Prisma client singleton (`prisma`).
encryption.ts # AES-256-GCM encrypt()/decrypt() using ENCRYPTION_KEY.
logger.ts # pino logger + createLogger("COMPONENT") child factory.
errors.ts # ERROR_MESSAGES map (code + message constants).
response.ts # makeResponse()/endResponse() — the standard JSON envelope.
adminSettings.ts # getAdminSettings() — reads/creates the single AdminSettings row (id=1).
middlewares/
cors.ts # CORS (allows PP_BASE_URL origin; wide-open in development).
main.ts # Handles OPTIONS preflight.
auth.ts # authResolution (adds ctr.getAuth()) + authEnforcement middleware.
routes/
auth.ts # login/logout/me/setup-status/first-user.
webhook.ts # POST /webhook/{userId} — HMAC verify, enqueue jobs. The Gitea entrypoint.
api/user.ts # User settings: gitea creds, aws creds, webhook secret.
api/repos.ts # Repo discovery + enable/disable (registers/deletes Gitea webhooks).
api/previews.ts # List/get/stop previews + WebSocket log stream.
api/admin.ts # Admin: user CRUD, global settings, all-previews dashboard.
services/
ec2.ts # AWS EC2/STS: key pairs, security groups, launch, terminate, AMI map, bootstrap script.
ssh.ts # ssh2 wrapper: connectSsh(), exec with timeout, abort support.
gitea.ts # Gitea REST client (axios): repos, webhooks, comments, permissions, PR comment body builder.
deploy.ts # THE deploy engine: firstDeploy/redeploy/stopPreview, log streaming, abort signals.
orphanCleanup.ts # On boot: terminate EC2s tagged pp:managed that have no active Preview.
workers/
jobWorker.ts # Polls Job table, serializes per-preview, new DEPLOY cancels running DEPLOY.
cronWorker.ts # node-cron: inactivity check (30m) + daily cleanup (03:00).
types/node-cron.d.ts # Ambient types.
```
### Frontend source map (`frontend/src/`)
```
main.tsx / App.tsx # Router (react-router-dom v7). ProtectedRoute + SetupCheck gating.
services/api.ts # Typed fetch wrapper (credentials: include) + openLogsWs() WebSocket helper.
hooks/useAuth.ts # Auth context/provider (calls /api/auth/me).
hooks/useTheme.ts # Dark/light theme, persisted to localStorage.
pages/ # Login, Dashboard, PreviewDetail, Repos, Settings, Admin, SetupWizard, Privacy.
components/ # Layout, LogViewer, StatusBadge, ConfirmDialog.
```
---
## Tech stack & versions
| Layer | Choice | Notes |
|---|---|---|
| Runtime | **Node 24** (alpine in Docker) | `packageManager` pins **pnpm 11.5.2** (hash-verified via corepack). |
| Package manager | **pnpm** workspaces | Do **not** use `npm`/`yarn`. A stray `package-lock.json` exists but pnpm is authoritative. |
| Backend HTTP | **rjweb-server ^9.8.6** + `@rjweb/runtime-node` | v9 API — see gotchas below. |
| Backend build | **esbuild** → CJS to `dist/` | Not `tsc` for runtime; `tsc` is only used by the frontend build. |
| ORM / DB | **Prisma ^6** + **PostgreSQL 18** | Schema at `./prisma/schema.prisma` (repo root). |
| Auth hashing | **bcryptjs** | NOT native `bcrypt` (Node 24 compat). |
| AWS | `@aws-sdk/client-ec2`, `@aws-sdk/client-sts` v3 | |
| SSH | **ssh2** | Connects to EC2 as user `ubuntu`. |
| HTTP client | **axios** | Gitea REST calls. |
| Validation | **zod** | Env schema; ad-hoc input checks in handlers. |
| Logging | **pino** (+ pino-pretty in dev) | |
| Scheduling | **node-cron** | |
| Frontend | **React 19**, **Vite 6**, **Tailwind 3**, **react-router-dom 7**, **motion**, **react-toastify** | |
---
## Commands
Run backend/frontend commands from **their own package dir** (`cd backend` / `cd frontend`). This is Windows/PowerShell — chain with `;` (not `&&`) if needed, or just run the dedicated tool.
### Local development
```bash
# 1. Start Postgres (compose, just the db)
docker compose up -d pp-db
# 2. Apply migrations (schema path is RELATIVE and points OUTSIDE backend/)
cd backend; npx prisma migrate dev --schema=../prisma/schema.prisma
# or: pnpm --filter pp-backend migrate
# 3. Backend (esbuild → dist → node), serves API on :5000
cd backend; pnpm install; pnpm dev
# 4. Frontend dev server on :3000, proxies /api and /webhook → :5000
cd frontend; pnpm install; pnpm dev
```
### Build / production
```bash
cd backend; pnpm build # esbuild src → dist
cd backend; pnpm start # node dist/index.js (cwd must be dist)
cd backend; pnpm prod # build + start
cd frontend; pnpm build # tsc typecheck + vite build → frontend/dist
# Whole stack (build image, run backend + db, auto-runs `prisma migrate deploy` on start)
docker compose up -d
```
### Prisma
```bash
cd backend; pnpm generate # prisma generate --schema=../prisma/schema.prisma
cd backend; pnpm migrate # migrate dev
cd backend; pnpm migrate:deploy # migrate deploy (used by the Docker CMD)
```
> ⚠️ **Every Prisma command needs `--schema=../prisma/schema.prisma`** because the schema is at the repo root, not in `backend/`. The package.json scripts already include it — prefer them.
There are **no automated tests** (V1 decision). Verify changes by running the stack. Do not add a test runner unless asked.
---
## Environment variables
Validated at boot by `backend/src/lib/env.ts` (zod). Backend loads `backend/.env`. Missing/invalid vars **throw and prevent startup** — this is intentional.
| Var | Required | Purpose |
|---|---|---|
| `DATABASE_URL` | yes | Postgres connection string. |
| `SESSION_SECRET` | yes (min 16) | Session cookie value signing. |
| `PP_BASE_URL` | yes | Public URL of this PP instance. Used in webhook URLs, PR comment links, CORS allow-list. No trailing slash. |
| `ENCRYPTION_KEY` | yes | **Exactly 64 hex chars** (32-byte AES-256). Encrypts Gitea PAT, AWS creds, SSH keys at rest. Changing it invalidates all stored secrets. |
| `PORT` | no (default 5000) | Backend port. |
| `LOG_LEVEL` | no (default info) | trace/debug/info/warn/error. |
| `NODE_ENV` | no (default development) | development/production/test. |
Generate secrets: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
---
## How the system works (data + control flow)
### Request → deploy pipeline
1. **Gitea** fires a webhook → `POST /webhook/{userId}` (`routes/webhook.ts`).
2. Handler: rate-limits per user (in-memory), **HMAC-SHA256 verifies** the raw body against the user's `WebhookToken.token`, returns `200 OK` **immediately**, then processes async via `setImmediate`.
3. It creates/updates a `Preview` row and enqueues a `Job` (`DEPLOY`/`STOP`) in Postgres. Webhooks never do slow work inline.
4. **`jobWorker.ts`** polls the `Job` table (~1s), enforces **one active job per `previewId`**, and — key rule — a **new `DEPLOY` aborts a currently-running `DEPLOY`** for the same preview (via `signalAbort` → SSH session `.abort()`).
5. **`deploy.ts`** does the real work over SSH: `firstDeploy` (provision EC2, clone, setup, build, run) or `redeploy` (reuse instance, pull, rebuild, restart). Status transitions: `PROVISIONING → BUILDING → RUNNING` (or `FAILED`).
6. Throughout, log output is appended to `Preview.logs` (capped at `AdminSettings.logSizeLimitBytes`, oldest lines truncated) and **broadcast over WebSocket** to `PreviewDetail` via `subscribeToLogs`.
7. PR comment is edited in place through `gitea.ts` using `Preview.giteaCommentId`.
### Background loops (`cronWorker.ts`)
- **Every 30 min**: any `RUNNING` preview idle past `RepoConfig.inactivityHours` gets an `INACTIVITY_STOP` job (deduped).
- **Daily 03:00**: purge `STOPPED`/`FAILED` previews older than `AdminSettings.previewRetentionDays` (+ their jobs).
### On boot (`index.ts` → `.start()` callback)
`prisma.$connect()` → seed `AdminSettings``startJobWorker()` (resets stuck `RUNNING` jobs → `PENDING`) → `startCronWorkers()``runOrphanCleanup()` (terminate `pp:managed` EC2s with no active Preview).
### EC2 lifecycle (`ec2.ts` + `deploy.ts`)
- Per preview: ephemeral **RSA key pair** (`CreateKeyPair`, name `pp-preview-<id>`) → **security group** `pp-preview-<id>` (opens 22 + app port) → **launch** Ubuntu 22.04 (per-region AMI map) with a `UserData` bootstrap script → poll for `running` + public IP.
- Bootstrap installs only PP's base SSH/deploy dependencies. Repo-selected preinstall options add Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages over SSH.
- Every instance is tagged `pp:managed`, `pp:userId`, `pp:repo`, `pp:prNumber`, `pp:previewId`.
- On stop: terminate instance → delete key pair → delete SG (after ~30s delay for ENI detach) → null out `sshPrivateKey`/`sshKeyName`/`instanceId`.
### PP commands (in PR comments)
Parsed in `webhook.ts` (`handleIssueCommentEvent`). A comment is a command if its first line starts with `/pp `. Only the PR author or a repo owner/admin may run them; PP ignores its own comments. Commands: `/pp rebuild`, `/pp stop`, `/pp start`, `/pp logs`, `/pp ignore`.
---
## Conventions (match these)
### Backend HTTP handlers
- Handlers take a single `ctr: any` (rjweb context) and are registered in **`index.ts`** — every route is declared there, grouped by area. There is no file-based routing.
- **Return responses via `makeResponse({ ctr, content: { code, message?, data? } })`** (`lib/response.ts`). The envelope is:
- success: `{ status: "OK", message?, data? }`
- failure (code ≥ 400): `{ status: "FAILED", message }`
- 5xx messages are replaced with a generic string automatically.
- Auth inside a handler: `const auth = ctr.getAuth?.();` then check `auth?.success` and use `auth.user`. Several route files define a local `requireAuth(ctr)` helper — reuse that pattern.
- Read JSON body with `await ctr.body()`; raw body (webhook HMAC) with `await ctr.$body().text()`.
- Route/path params: `ctr.params.get("name")`. Query/headers/cookies: `ctr.headers.get(...)`, `ctr.cookies.get(...)`.
### rjweb-server v9 gotchas (important)
- **`path.http(METHOD, fullPath, ...)` takes the FULL path**, not one relative to the `.path("/")` prefix. All routes are registered under `.path("/")` with absolute paths like `/api/repos/{owner}/{repo}/config`.
- **URL params use `{braces}`**, e.g. `/webhook/{userId}`, `/api/previews/{id}` — not `:colon`.
- **Static UI is registered LAST**, after all API routes, and `notFound` hand-serves `index.html` for non-API/non-asset paths (SPA fallback).
- Middleware order in the `Server` constructor matters: cors → main (OPTIONS) → authResolution → authEnforcement.
### Secrets & security
- **Anything sensitive is encrypted at rest**: Gitea PAT, AWS access key + secret, SSH private keys go through `encrypt()` before DB writes and `decrypt()` on read. Never store them in plaintext.
- **Never log or echo secrets.** `deploy.ts` deliberately masks the PAT out of git output and passes it via `git -c http.extraHeader` rather than in the clone URL. Preserve this.
- API responses **never return raw secrets** — they return `"****"`, booleans like `giteaPatSet`, or omit the field (see `auth.ts` `meHandler`, and `repoConfig` responses strip `giteaWebhookId`).
- Webhook signatures are compared with a constant-time equal. Keep it constant-time.
### Database
- Single Prisma client from `lib/db.ts` — import `{ prisma }`, don't `new PrismaClient()`.
- `AdminSettings` is a **singleton row `id = 1`** — always go through `getAdminSettings()` (creates it if missing).
- Enums live in the schema: `PreviewStatus`, `JobType`, `JobStatus`. Reuse them; don't invent string statuses.
### Logging
- `import { createLogger } from "../lib/logger"` and `const log = createLogger("COMPONENT")`. pino style: `log.info({ structuredFields }, "message")`.
### Frontend
- All API calls go through the `api` object in `services/api.ts` (adds `credentials: "include"`). Add new endpoints there rather than calling `fetch` directly in components.
- Live logs use `openLogsWs(previewId, onMessage)` (auto-picks ws/wss).
- Toasts via `react-toastify`; confirmations via `components/ConfirmDialog`. Theme via `useTheme` (localStorage).
- Setup gating: `ProtectedRoute` (requires auth) wraps `SetupCheck` (redirects to `/setup` until `user.setupComplete`).
### Style
- TypeScript, 2-space indent, double quotes, semicolons — match the surrounding file. Handlers are terminal (`return makeResponse(...)`), services throw and let the deploy engine/worker catch.
---
## Known sharp edges / decisions already made
Don't "fix" these without a reason — they were deliberate (see git history / project memory):
- **pnpm, not npm.** Ignore `package-lock.json`.
- **bcryptjs, not bcrypt** (Node 24).
- **`CreateKeyPair`, not `ImportKeyPair`** — avoids OpenSSH wire-format encoding issues; AWS returns the PEM we store encrypted.
- **SSH as `ubuntu`, not `root`.** Selected preinstall tools are installed from the deploy flow; Node commands are wrapped with `NVM_PREFIX` only when Node is selected. Recent commits specifically moved off root.
- **Prisma schema is at repo root** (`./prisma/schema.prisma`), so every command needs `--schema=../prisma/schema.prisma`.
- **rjweb full paths** in `path.http()` (see above).
- **Docker uses corepack + frozen lockfile** with the pinned pnpm to keep supply-chain policy deterministic; workspace-level install so `pnpm-workspace.yaml` settings apply.
- **Webhook returns 200 before processing** — all deploy work is async through the Job queue; keep it that way.
---
## Current work
Active branch: **`v2/full-spec-implementation`** (recent commits are `fix(v2): ...` hardening the EC2 bootstrap/SSH/deploy path). `main` is the stable branch and the usual PR base. `TODO.md` tracks UI/UX polish items still open (theming, repo search/filtering, config-as-its-own-page, favicon/SEO, tag-input bugs).
Gitea webhooks are registered for `pull_request`, **`pull_request_sync`** (pushes to a PR branch — a *separate* Gitea event; without it previews never redeploy on push), and `issue_comment`, and are named `PR Previews`. On boot, `services/webhookReconcile.ts` backfills these onto existing webhooks. Any PATCH to a Gitea webhook MUST re-send the full `events` array — Gitea resets an omitted/empty `events` to push-only. Also: Gitea's PR-sync action string is **`synchronized`** (past tense), not GitHub's `synchronize``webhook.ts` accepts both.
Before committing: only commit when asked; branch off `main` if you're on it; end commit messages with the required `Co-Authored-By` trailer.
---
## Where to look first
- **Changing deploy/build/run behavior on the EC2** → `services/deploy.ts` (+ `services/ssh.ts`, `services/ec2.ts`).
- **Webhook / PP command handling** → `routes/webhook.ts`.
- **Job scheduling / cancellation** → `workers/jobWorker.ts`.
- **Gitea API calls / PR comment format** → `services/gitea.ts`.
- **Adding an API endpoint** → write the handler in `routes/…`, then register it in `index.ts`.
- **Data model change** → `prisma/schema.prisma`, then `pnpm --filter pp-backend migrate`.
- **What the product is supposed to do** → `SPEC.md`.
+273
View File
@@ -0,0 +1,273 @@
# CLAUDE.md
Guidance for AI agents (and humans) working in this repository. Read this before making changes.
> **This file is mirrored 1:1 to `AGENTS.md`.** If you edit one, make the identical edit to the other. They must stay byte-for-byte identical.
---
## What this project is
**PR Previews (PP)** is a self-hosted service that connects to a **Gitea** instance via webhook. When a PR is opened or updated, PP:
1. Provisions an **AWS EC2** instance in the user's own AWS account,
2. Clones the PR branch, installs deps, builds, and runs the app over SSH,
3. Posts a **live preview URL** (`http://<ec2-ip>:<port>`) back onto the PR as an in-place-edited comment.
It is built for teams and agent-driven workflows where reviewers want to see a running PR without pulling code locally. **Gitea only** — no GitHub/GitLab. One preview per PR; the EC2 instance is reused across pushes.
The canonical product/behavior spec is [`SPEC.md`](./SPEC.md). When behavior is ambiguous, **SPEC.md wins** — this file describes *how the code is built*, SPEC.md describes *what it must do*.
---
## Repository layout
This is a **pnpm workspace monorepo** (`pnpm-workspace.yaml``backend`, `frontend`).
```
./backend/ # Node.js API + workers (rjweb-server + Prisma). Compiled with esbuild.
./frontend/ # React 19 + Vite + Tailwind SPA.
./prisma/ # Prisma schema + migrations — LIVES AT REPO ROOT, not in backend/.
schema.prisma
migrations/
./Dockerfile # Multi-stage build for the whole app (frontend + backend + prisma).
./docker-compose.yml # Runs PP itself: pp-backend + pp-db (Postgres). NOT the preview EC2s.
./SPEC.md # Product/behavior specification (source of truth for behavior).
./example.env # Env var template.
./TODO.md # Known follow-ups / rough edges (untracked scratch list).
```
### Backend source map (`backend/src/`)
```
index.ts # Server bootstrap: builds rjweb Server, registers ALL routes, starts workers.
lib/
env.ts # Zod-validated process.env. Loads backend/.env. Throws on invalid env at boot.
db.ts # Prisma client singleton (`prisma`).
encryption.ts # AES-256-GCM encrypt()/decrypt() using ENCRYPTION_KEY.
logger.ts # pino logger + createLogger("COMPONENT") child factory.
errors.ts # ERROR_MESSAGES map (code + message constants).
response.ts # makeResponse()/endResponse() — the standard JSON envelope.
adminSettings.ts # getAdminSettings() — reads/creates the single AdminSettings row (id=1).
middlewares/
cors.ts # CORS (allows PP_BASE_URL origin; wide-open in development).
main.ts # Handles OPTIONS preflight.
auth.ts # authResolution (adds ctr.getAuth()) + authEnforcement middleware.
routes/
auth.ts # login/logout/me/setup-status/first-user.
webhook.ts # POST /webhook/{userId} — HMAC verify, enqueue jobs. The Gitea entrypoint.
api/user.ts # User settings: gitea creds, aws creds, webhook secret.
api/repos.ts # Repo discovery + enable/disable (registers/deletes Gitea webhooks).
api/previews.ts # List/get/stop previews + WebSocket log stream.
api/admin.ts # Admin: user CRUD, global settings, all-previews dashboard.
services/
ec2.ts # AWS EC2/STS: key pairs, security groups, launch, terminate, AMI map, bootstrap script.
ssh.ts # ssh2 wrapper: connectSsh(), exec with timeout, abort support.
gitea.ts # Gitea REST client (axios): repos, webhooks, comments, permissions, PR comment body builder.
deploy.ts # THE deploy engine: firstDeploy/redeploy/stopPreview, log streaming, abort signals.
orphanCleanup.ts # On boot: terminate EC2s tagged pp:managed that have no active Preview.
workers/
jobWorker.ts # Polls Job table, serializes per-preview, new DEPLOY cancels running DEPLOY.
cronWorker.ts # node-cron: inactivity check (30m) + daily cleanup (03:00).
types/node-cron.d.ts # Ambient types.
```
### Frontend source map (`frontend/src/`)
```
main.tsx / App.tsx # Router (react-router-dom v7). ProtectedRoute + SetupCheck gating.
services/api.ts # Typed fetch wrapper (credentials: include) + openLogsWs() WebSocket helper.
hooks/useAuth.ts # Auth context/provider (calls /api/auth/me).
hooks/useTheme.ts # Dark/light theme, persisted to localStorage.
pages/ # Login, Dashboard, PreviewDetail, Repos, Settings, Admin, SetupWizard, Privacy.
components/ # Layout, LogViewer, StatusBadge, ConfirmDialog.
```
---
## Tech stack & versions
| Layer | Choice | Notes |
|---|---|---|
| Runtime | **Node 24** (alpine in Docker) | `packageManager` pins **pnpm 11.5.2** (hash-verified via corepack). |
| Package manager | **pnpm** workspaces | Do **not** use `npm`/`yarn`. A stray `package-lock.json` exists but pnpm is authoritative. |
| Backend HTTP | **rjweb-server ^9.8.6** + `@rjweb/runtime-node` | v9 API — see gotchas below. |
| Backend build | **esbuild** → CJS to `dist/` | Not `tsc` for runtime; `tsc` is only used by the frontend build. |
| ORM / DB | **Prisma ^6** + **PostgreSQL 18** | Schema at `./prisma/schema.prisma` (repo root). |
| Auth hashing | **bcryptjs** | NOT native `bcrypt` (Node 24 compat). |
| AWS | `@aws-sdk/client-ec2`, `@aws-sdk/client-sts` v3 | |
| SSH | **ssh2** | Connects to EC2 as user `ubuntu`. |
| HTTP client | **axios** | Gitea REST calls. |
| Validation | **zod** | Env schema; ad-hoc input checks in handlers. |
| Logging | **pino** (+ pino-pretty in dev) | |
| Scheduling | **node-cron** | |
| Frontend | **React 19**, **Vite 6**, **Tailwind 3**, **react-router-dom 7**, **motion**, **react-toastify** | |
---
## Commands
Run backend/frontend commands from **their own package dir** (`cd backend` / `cd frontend`). This is Windows/PowerShell — chain with `;` (not `&&`) if needed, or just run the dedicated tool.
### Local development
```bash
# 1. Start Postgres (compose, just the db)
docker compose up -d pp-db
# 2. Apply migrations (schema path is RELATIVE and points OUTSIDE backend/)
cd backend; npx prisma migrate dev --schema=../prisma/schema.prisma
# or: pnpm --filter pp-backend migrate
# 3. Backend (esbuild → dist → node), serves API on :5000
cd backend; pnpm install; pnpm dev
# 4. Frontend dev server on :3000, proxies /api and /webhook → :5000
cd frontend; pnpm install; pnpm dev
```
### Build / production
```bash
cd backend; pnpm build # esbuild src → dist
cd backend; pnpm start # node dist/index.js (cwd must be dist)
cd backend; pnpm prod # build + start
cd frontend; pnpm build # tsc typecheck + vite build → frontend/dist
# Whole stack (build image, run backend + db, auto-runs `prisma migrate deploy` on start)
docker compose up -d
```
### Prisma
```bash
cd backend; pnpm generate # prisma generate --schema=../prisma/schema.prisma
cd backend; pnpm migrate # migrate dev
cd backend; pnpm migrate:deploy # migrate deploy (used by the Docker CMD)
```
> ⚠️ **Every Prisma command needs `--schema=../prisma/schema.prisma`** because the schema is at the repo root, not in `backend/`. The package.json scripts already include it — prefer them.
There are **no automated tests** (V1 decision). Verify changes by running the stack. Do not add a test runner unless asked.
---
## Environment variables
Validated at boot by `backend/src/lib/env.ts` (zod). Backend loads `backend/.env`. Missing/invalid vars **throw and prevent startup** — this is intentional.
| Var | Required | Purpose |
|---|---|---|
| `DATABASE_URL` | yes | Postgres connection string. |
| `SESSION_SECRET` | yes (min 16) | Session cookie value signing. |
| `PP_BASE_URL` | yes | Public URL of this PP instance. Used in webhook URLs, PR comment links, CORS allow-list. No trailing slash. |
| `ENCRYPTION_KEY` | yes | **Exactly 64 hex chars** (32-byte AES-256). Encrypts Gitea PAT, AWS creds, SSH keys at rest. Changing it invalidates all stored secrets. |
| `PORT` | no (default 5000) | Backend port. |
| `LOG_LEVEL` | no (default info) | trace/debug/info/warn/error. |
| `NODE_ENV` | no (default development) | development/production/test. |
Generate secrets: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
---
## How the system works (data + control flow)
### Request → deploy pipeline
1. **Gitea** fires a webhook → `POST /webhook/{userId}` (`routes/webhook.ts`).
2. Handler: rate-limits per user (in-memory), **HMAC-SHA256 verifies** the raw body against the user's `WebhookToken.token`, returns `200 OK` **immediately**, then processes async via `setImmediate`.
3. It creates/updates a `Preview` row and enqueues a `Job` (`DEPLOY`/`STOP`) in Postgres. Webhooks never do slow work inline.
4. **`jobWorker.ts`** polls the `Job` table (~1s), enforces **one active job per `previewId`**, and — key rule — a **new `DEPLOY` aborts a currently-running `DEPLOY`** for the same preview (via `signalAbort` → SSH session `.abort()`).
5. **`deploy.ts`** does the real work over SSH: `firstDeploy` (provision EC2, clone, setup, build, run) or `redeploy` (reuse instance, pull, rebuild, restart). Status transitions: `PROVISIONING → BUILDING → RUNNING` (or `FAILED`).
6. Throughout, log output is appended to `Preview.logs` (capped at `AdminSettings.logSizeLimitBytes`, oldest lines truncated) and **broadcast over WebSocket** to `PreviewDetail` via `subscribeToLogs`.
7. PR comment is edited in place through `gitea.ts` using `Preview.giteaCommentId`.
### Background loops (`cronWorker.ts`)
- **Every 30 min**: any `RUNNING` preview idle past `RepoConfig.inactivityHours` gets an `INACTIVITY_STOP` job (deduped).
- **Daily 03:00**: purge `STOPPED`/`FAILED` previews older than `AdminSettings.previewRetentionDays` (+ their jobs).
### On boot (`index.ts` → `.start()` callback)
`prisma.$connect()` → seed `AdminSettings``startJobWorker()` (resets stuck `RUNNING` jobs → `PENDING`) → `startCronWorkers()``runOrphanCleanup()` (terminate `pp:managed` EC2s with no active Preview).
### EC2 lifecycle (`ec2.ts` + `deploy.ts`)
- Per preview: ephemeral **RSA key pair** (`CreateKeyPair`, name `pp-preview-<id>`) → **security group** `pp-preview-<id>` (opens 22 + app port) → **launch** Ubuntu 22.04 (per-region AMI map) with a `UserData` bootstrap script → poll for `running` + public IP.
- Bootstrap installs only PP's base SSH/deploy dependencies. Repo-selected preinstall options add Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages over SSH.
- Every instance is tagged `pp:managed`, `pp:userId`, `pp:repo`, `pp:prNumber`, `pp:previewId`.
- On stop: terminate instance → delete key pair → delete SG (after ~30s delay for ENI detach) → null out `sshPrivateKey`/`sshKeyName`/`instanceId`.
### PP commands (in PR comments)
Parsed in `webhook.ts` (`handleIssueCommentEvent`). A comment is a command if its first line starts with `/pp `. Only the PR author or a repo owner/admin may run them; PP ignores its own comments. Commands: `/pp rebuild`, `/pp stop`, `/pp start`, `/pp logs`, `/pp ignore`.
---
## Conventions (match these)
### Backend HTTP handlers
- Handlers take a single `ctr: any` (rjweb context) and are registered in **`index.ts`** — every route is declared there, grouped by area. There is no file-based routing.
- **Return responses via `makeResponse({ ctr, content: { code, message?, data? } })`** (`lib/response.ts`). The envelope is:
- success: `{ status: "OK", message?, data? }`
- failure (code ≥ 400): `{ status: "FAILED", message }`
- 5xx messages are replaced with a generic string automatically.
- Auth inside a handler: `const auth = ctr.getAuth?.();` then check `auth?.success` and use `auth.user`. Several route files define a local `requireAuth(ctr)` helper — reuse that pattern.
- Read JSON body with `await ctr.body()`; raw body (webhook HMAC) with `await ctr.$body().text()`.
- Route/path params: `ctr.params.get("name")`. Query/headers/cookies: `ctr.headers.get(...)`, `ctr.cookies.get(...)`.
### rjweb-server v9 gotchas (important)
- **`path.http(METHOD, fullPath, ...)` takes the FULL path**, not one relative to the `.path("/")` prefix. All routes are registered under `.path("/")` with absolute paths like `/api/repos/{owner}/{repo}/config`.
- **URL params use `{braces}`**, e.g. `/webhook/{userId}`, `/api/previews/{id}` — not `:colon`.
- **Static UI is registered LAST**, after all API routes, and `notFound` hand-serves `index.html` for non-API/non-asset paths (SPA fallback).
- Middleware order in the `Server` constructor matters: cors → main (OPTIONS) → authResolution → authEnforcement.
### Secrets & security
- **Anything sensitive is encrypted at rest**: Gitea PAT, AWS access key + secret, SSH private keys go through `encrypt()` before DB writes and `decrypt()` on read. Never store them in plaintext.
- **Never log or echo secrets.** `deploy.ts` deliberately masks the PAT out of git output and passes it via `git -c http.extraHeader` rather than in the clone URL. Preserve this.
- API responses **never return raw secrets** — they return `"****"`, booleans like `giteaPatSet`, or omit the field (see `auth.ts` `meHandler`, and `repoConfig` responses strip `giteaWebhookId`).
- Webhook signatures are compared with a constant-time equal. Keep it constant-time.
### Database
- Single Prisma client from `lib/db.ts` — import `{ prisma }`, don't `new PrismaClient()`.
- `AdminSettings` is a **singleton row `id = 1`** — always go through `getAdminSettings()` (creates it if missing).
- Enums live in the schema: `PreviewStatus`, `JobType`, `JobStatus`. Reuse them; don't invent string statuses.
### Logging
- `import { createLogger } from "../lib/logger"` and `const log = createLogger("COMPONENT")`. pino style: `log.info({ structuredFields }, "message")`.
### Frontend
- All API calls go through the `api` object in `services/api.ts` (adds `credentials: "include"`). Add new endpoints there rather than calling `fetch` directly in components.
- Live logs use `openLogsWs(previewId, onMessage)` (auto-picks ws/wss).
- Toasts via `react-toastify`; confirmations via `components/ConfirmDialog`. Theme via `useTheme` (localStorage).
- Setup gating: `ProtectedRoute` (requires auth) wraps `SetupCheck` (redirects to `/setup` until `user.setupComplete`).
### Style
- TypeScript, 2-space indent, double quotes, semicolons — match the surrounding file. Handlers are terminal (`return makeResponse(...)`), services throw and let the deploy engine/worker catch.
---
## Known sharp edges / decisions already made
Don't "fix" these without a reason — they were deliberate (see git history / project memory):
- **pnpm, not npm.** Ignore `package-lock.json`.
- **bcryptjs, not bcrypt** (Node 24).
- **`CreateKeyPair`, not `ImportKeyPair`** — avoids OpenSSH wire-format encoding issues; AWS returns the PEM we store encrypted.
- **SSH as `ubuntu`, not `root`.** Selected preinstall tools are installed from the deploy flow; Node commands are wrapped with `NVM_PREFIX` only when Node is selected. Recent commits specifically moved off root.
- **Prisma schema is at repo root** (`./prisma/schema.prisma`), so every command needs `--schema=../prisma/schema.prisma`.
- **rjweb full paths** in `path.http()` (see above).
- **Docker uses corepack + frozen lockfile** with the pinned pnpm to keep supply-chain policy deterministic; workspace-level install so `pnpm-workspace.yaml` settings apply.
- **Webhook returns 200 before processing** — all deploy work is async through the Job queue; keep it that way.
---
## Current work
Active branch: **`v2/full-spec-implementation`** (recent commits are `fix(v2): ...` hardening the EC2 bootstrap/SSH/deploy path). `main` is the stable branch and the usual PR base. `TODO.md` tracks UI/UX polish items still open (theming, repo search/filtering, config-as-its-own-page, favicon/SEO, tag-input bugs).
Gitea webhooks are registered for `pull_request`, **`pull_request_sync`** (pushes to a PR branch — a *separate* Gitea event; without it previews never redeploy on push), and `issue_comment`, and are named `PR Previews`. On boot, `services/webhookReconcile.ts` backfills these onto existing webhooks. Any PATCH to a Gitea webhook MUST re-send the full `events` array — Gitea resets an omitted/empty `events` to push-only. Also: Gitea's PR-sync action string is **`synchronized`** (past tense), not GitHub's `synchronize``webhook.ts` accepts both.
Before committing: only commit when asked; branch off `main` if you're on it; end commit messages with the required `Co-Authored-By` trailer.
---
## Where to look first
- **Changing deploy/build/run behavior on the EC2** → `services/deploy.ts` (+ `services/ssh.ts`, `services/ec2.ts`).
- **Webhook / PP command handling** → `routes/webhook.ts`.
- **Job scheduling / cancellation** → `workers/jobWorker.ts`.
- **Gitea API calls / PR comment format** → `services/gitea.ts`.
- **Adding an API endpoint** → write the handler in `routes/…`, then register it in `index.ts`.
- **Data model change** → `prisma/schema.prisma`, then `pnpm --filter pp-backend migrate`.
- **What the product is supposed to do** → `SPEC.md`.
+76
View File
@@ -0,0 +1,76 @@
# Contributing
Thanks for working on PR Previews. This project is a self-hosted Gitea PR
preview service; the product behavior source of truth is `SPEC.md`.
## Before You Change Code
- Read `AGENTS.md` for repository-specific implementation rules.
- Check `SPEC.md` when behavior is ambiguous.
- Use `pnpm`, not npm or yarn.
- Keep Prisma commands pointed at `../prisma/schema.prisma` from `backend/`.
- Do not commit generated secrets, `.env` files, EC2 keys, tokens, or database dumps.
## Development Setup
Start the database:
```bash
docker compose up -d pp-db
```
Apply migrations:
```bash
cd backend
pnpm migrate
```
Run the backend:
```bash
cd backend
pnpm install
pnpm dev
```
Run the frontend:
```bash
cd frontend
pnpm install
pnpm dev
```
## Validation
There is no automated test runner in this repository yet. Before opening a PR,
run the relevant builds:
```bash
cd backend
pnpm build
```
```bash
cd frontend
pnpm build
```
For behavior changes, also verify the running stack manually with a Gitea PR or
with the narrowest API/UI flow that exercises the change.
## Pull Request Guidelines
- Keep changes scoped to one behavior or cleanup.
- Explain user-visible behavior changes clearly.
- Include migration notes when changing `prisma/schema.prisma`.
- Preserve the existing security model: encrypted stored secrets, masked logs,
constant-time webhook signature checks, and no secret values in API responses.
- Update `README.md`, `SPEC.md`, or `AGENTS.md` when the change affects setup,
product behavior, or contributor workflow.
## License
By contributing, you agree that your contributions are licensed under the Apache
License, Version 2.0. See `LICENSE`.
+16 -11
View File
@@ -8,7 +8,7 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/
COPY prisma/ ./prisma/
COPY prisma/schema.prisma ./prisma/schema.prisma
# corepack activates the exact pnpm version declared in package.json#packageManager.
# This is hash-verified (sha1) and prevents newer pnpm from applying different
@@ -17,28 +17,33 @@ COPY prisma/ ./prisma/
RUN corepack enable && corepack install && \
pnpm install --frozen-lockfile
# Stage 2: Build frontend
# Stage 2: Prune runtime dependencies
FROM deps AS prod-deps
COPY prisma/ ./prisma/
RUN CI=true pnpm prune --prod
# Stage 3: Build frontend
FROM deps AS frontend-builder
COPY frontend/ ./frontend/
RUN pnpm --filter pp-frontend run build
# Stage 3: Build backend + Prisma client
# Stage 4: Build backend + Prisma client
FROM deps AS backend-builder
COPY backend/ ./backend/
COPY prisma/ ./prisma/
RUN pnpm --filter pp-backend run generate && \
pnpm --filter pp-backend run build
pnpm --filter pp-backend run build:prod
# Stage 4: Minimal production runner
# Stage 5: Minimal production runner
FROM node:24-alpine AS runner
WORKDIR /app
RUN apk add --no-cache openssl
ENV NODE_PATH=/app/node_modules/.pnpm/node_modules
# Root node_modules contains prisma CLI and hoisted deps; backend node_modules has
# the backend-specific symlinks. Both are needed at runtime.
COPY --from=backend-builder /app/node_modules ./node_modules
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=prod-deps /app/backend/node_modules ./backend/node_modules
COPY --from=prod-deps /app/backend/package.json ./backend/package.json
COPY --from=backend-builder /app/backend/dist ./backend/dist
COPY --from=backend-builder /app/backend/node_modules ./backend/node_modules
COPY --from=backend-builder /app/backend/package.json ./backend/package.json
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
COPY prisma/ ./prisma/
@@ -46,4 +51,4 @@ WORKDIR /app/backend
EXPOSE 5000
CMD sh -c "../node_modules/.bin/prisma migrate deploy --schema=../prisma/schema.prisma && node dist/index.js"
CMD sh -c "../node_modules/.pnpm/node_modules/.bin/prisma migrate deploy --schema=../prisma/schema.prisma && node dist/index.js"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 PR Previews contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+287 -63
View File
@@ -1,97 +1,321 @@
# PR Previews (PP)
> Self-hosted service that connects to a Gitea instance via webhook. When a PR is opened or updated, PP automatically provisions an AWS EC2 instance, builds and runs the project, and comments a live preview URL back on the PR.
PR Previews is a self-hosted preview environment service for Gitea pull requests.
When someone opens or updates a pull request, PP starts an EC2 instance in your AWS account, checks out the PR branch, builds the project, runs it, and posts the live preview URL back to the PR. Reviewers can open the running app without cloning the branch locally.
PP is built for Gitea only. It does not support GitHub or GitLab.
## Get Started
The fastest way to try PP is with Docker Compose:
```bash
cp example.env .env
```
Edit `.env` and set at least:
```env
SESSION_SECRET=<long random string>
PP_BASE_URL=https://your-public-pp-url.example.com
ENCRYPTION_KEY=<64 hex chars>
```
Generate `ENCRYPTION_KEY` with:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Then start PP:
```bash
docker compose up -d
```
Open `http://localhost:5000`, create the first user, and follow the setup wizard to connect Gitea, AWS, and your first repo.
## What It Does
- Watches Gitea pull requests through webhooks.
- Creates one preview environment per PR.
- Reuses the same EC2 instance when new commits are pushed.
- Streams deploy logs into the web UI.
- Edits a single PR comment with the current preview status and URL.
- Stops old previews manually, on PR close, or after inactivity.
- Lets users configure preinstalled runtimes/tools, build commands, run commands, env vars, ports, instance type, and Docker Compose usage per repo.
## How It Works
1. A Gitea webhook sends a pull request event to PP.
2. PP verifies the webhook signature and queues a deploy job.
3. The worker provisions or reuses an AWS EC2 instance.
4. PP connects over SSH, clones the PR branch, installs dependencies, builds, and starts the app.
5. PP posts or updates a Gitea PR comment with the preview URL.
Preview URLs look like:
```text
http://<ec2-public-ip>:<configured-port>
```
## Requirements
- A running Gitea instance.
- A Gitea personal access token for the account that should post preview comments.
- An AWS account with EC2 permissions.
- Docker and Docker Compose to run PP itself.
- A public `PP_BASE_URL` that Gitea can reach for webhooks.
For local-only testing, `PP_BASE_URL` still needs to be reachable by Gitea. Use a tunnel or a real public URL if your Gitea instance is not running on the same machine.
## Quick Start
### Prerequisites
1. Copy the environment template:
- Docker & Docker Compose
- Gitea instance with admin access
- AWS account with EC2 permissions
### Setup
1. **Clone and configure:**
```bash
cp example.env .env
# Edit .env — at minimum set SESSION_SECRET, PP_BASE_URL, and ENCRYPTION_KEY
# Generate ENCRYPTION_KEY: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
2. **Start services:**
2. Edit `.env`:
```env
SESSION_SECRET=<long random string>
PP_BASE_URL=https://pp.example.com
ENCRYPTION_KEY=<64 hex chars>
```
Generate a valid encryption key with:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
3. Start PP:
```bash
docker compose up -d
```
3. **Open the web UI** at `http://localhost:5000` (or your configured PP_BASE_URL)
4. Open the UI:
4. **Create your admin account** on first login — you'll be prompted for a username and password.
```text
http://localhost:5000
```
5. **Complete the setup wizard** to configure Gitea and AWS credentials.
5. Create the first user. The first user becomes the founder admin.
## Architecture
6. Complete the setup wizard:
```
./backend/ # Node.js backend (rjweb-server + Prisma)
./frontend/ # React + Vite + Tailwind CSS
./prisma/schema.prisma # PostgreSQL schema
./docker-compose.yml # PP itself (backend + db)
./Dockerfile # Multi-stage build
```
- Add your Gitea instance URL and PAT.
- Add your AWS credentials and region.
- Enable a repo.
- Configure how that repo should build and run.
## Environment Variables
| Variable | Description |
|---|---|
| `DATABASE_URL` | PostgreSQL connection string |
| `SESSION_SECRET` | Cookie signing secret (32+ random chars) |
| `PP_BASE_URL` | Public URL of this PP instance (no trailing slash) |
| `ENCRYPTION_KEY` | AES-256 key — 64 hex chars (32 bytes). Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
| `PORT` | Backend port (default: 5000) |
| `LOG_LEVEL` | Logging level: trace/debug/info/warn/error (default: info) |
| Variable | Required | Description |
|---|---:|---|
| `DATABASE_URL` | Yes | PostgreSQL connection string. In Docker Compose this is set automatically for the bundled database. |
| `SESSION_SECRET` | Yes | Secret used for session cookies. Use a long random value. |
| `PP_BASE_URL` | Yes | Public URL of this PP instance, with no trailing slash. Used for webhooks, CORS, and PR comment links. |
| `ENCRYPTION_KEY` | Yes | Exactly 64 hex characters. Encrypts Gitea PATs, AWS credentials, and SSH private keys at rest. |
| `PORT` | No | Backend port. Defaults to `5000`. |
| `LOG_LEVEL` | No | `trace`, `debug`, `info`, `warn`, or `error`. Defaults to `info`. |
| `NODE_ENV` | No | `development`, `production`, or `test`. Defaults to `development`. |
## PP Commands (in PR comments)
Important: changing `ENCRYPTION_KEY` after setup makes existing encrypted credentials unreadable.
| Command | Action |
|---|---|
| `/pp rebuild` | Re-run build on existing EC2 instance |
| `/pp stop` | Stop and terminate the preview |
| `/pp start` | Start or restart a stopped/ignored preview |
| `/pp logs` | Post last 50 lines of logs as a comment |
| `/pp ignore` | Ignore all future events for this PR |
## Gitea Setup
## Required AWS IAM Permissions
PP can register repo webhooks automatically when you enable a repo in the UI.
The Gitea PAT should belong to the account that should post PR comments. It needs permissions to:
- Read repositories.
- Read pull request and issue metadata.
- Write issue comments.
- Create, update, and delete repo webhooks.
PP registers webhooks named `PR Previews` with these events:
- `pull_request`
- `pull_request_sync`
- `issue_comment`
## AWS Setup
PP launches preview instances in your AWS account. Each preview gets its own EC2 instance, SSH key pair, and security group.
Required IAM policy:
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances",
"ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress", "ec2:DescribeSecurityGroups",
"ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:CreateTags",
"sts:GetCallerIdentity"
],
"Resource": "*"
}]
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups",
"ec2:CreateKeyPair",
"ec2:DeleteKeyPair",
"ec2:CreateTags",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}
```
## Development
Every EC2 instance created by PP is tagged with `pp:managed=true` plus user, repo, PR number, and preview ID metadata.
## Repo Configuration
Each enabled repo has its own preview configuration:
- EC2 instance type.
- Inactivity timeout.
- Public app port.
- Environment variables.
- Apt packages.
- Setup commands.
- Build commands.
- Post-build commands.
- Run command.
- Preinstall options for Docker + Compose, Node.js/version, Python, Go, Lua, build tools, and custom apt packages.
- Docker Compose mode and compose file path.
- Disabled `/pp` commands.
- Deny list of Gitea usernames to ignore.
Docker Compose mode runs:
```bash
# Database (via Docker)
docker compose up -d pp-db
# Run migrations
cd backend && npx prisma migrate dev --schema=../prisma/schema.prisma
# Backend (port 5000)
cd backend && pnpm install && pnpm dev
# Frontend dev server (port 3000, proxies API to backend)
cd frontend && pnpm install && pnpm dev
docker compose -f <compose-file> up -d --build --force-recreate
```
Non-compose mode runs your configured commands and starts the app with your configured run command.
## PR Commands
PP also listens for commands in PR comments. The first line must start with `/pp `.
| Command | Action |
|---|---|
| `/pp help` | Post the available command list. |
| `/pp rebuild` | Rebuild and restart the preview. If the preview is stopped, a new instance is provisioned. |
| `/pp stop` | Stop the preview and terminate its EC2 instance. |
| `/pp start` | Start a stopped preview, or unignore and deploy an ignored preview. |
| `/pp logs` | Post the last deploy log lines as a PR comment. |
| `/pp ignore` | Ignore future events for this PR until `/pp start` is used. |
Commands can be disabled per repo, except `/pp help`.
## Operating Notes
- Webhooks return quickly. Deploy work is handled asynchronously by the job worker.
- A newer deploy for the same PR cancels the currently running deploy.
- Stopped previews release their EC2 instance, SSH key pair, and security group.
- Stopped and failed preview records are cleaned up after the configured retention period.
- On startup, PP looks for orphaned managed EC2 instances and terminates ones that no longer match an active preview.
- Preview logs are stored in PostgreSQL and capped by the admin log size setting.
- Live app logs are available from the preview detail page while a preview is running.
## Security Model
- Gitea PATs, AWS credentials, and SSH private keys are encrypted at rest.
- Webhook payloads are verified with HMAC-SHA256 before processing.
- API responses do not return raw stored secrets.
- EC2 SSH keys are generated per preview launch and deleted on stop.
- Users can manage only their own repos and previews.
- Admins can manage users, global settings, and all previews.
Keep `ENCRYPTION_KEY` backed up. Losing it means PP cannot decrypt saved credentials.
## Development
This is a pnpm workspace monorepo:
```text
backend/ Node.js API and workers
frontend/ React, Vite, and Tailwind UI
prisma/ Prisma schema and migrations
```
Use pnpm, not npm or yarn.
Start only the database:
```bash
docker compose up -d pp-db
```
Apply migrations:
```bash
cd backend
pnpm migrate
```
Run the backend on port 5000:
```bash
cd backend
pnpm install
pnpm dev
```
Run the frontend dev server on port 3000:
```bash
cd frontend
pnpm install
pnpm dev
```
Build backend:
```bash
cd backend
pnpm build
```
Build frontend:
```bash
cd frontend
pnpm build
```
Prisma schema lives at `prisma/schema.prisma` in the repo root. The backend package scripts already pass the correct schema path.
## Production Deployment
The included Compose stack runs:
- `pp-db`: PostgreSQL.
- `pp-backend`: the backend API, workers, migrations, and built frontend.
Start or update the stack with:
```bash
docker compose up -d
```
The container runs Prisma migrations on startup before starting the backend.
## Project References
- Product behavior: `SPEC.md`
- Environment template: `example.env`
- Agent and contributor implementation notes: `AGENTS.md`
- Contributing guide: `CONTRIBUTING.md`
- Security policy: `SECURITY.md`
- License: Apache License 2.0 (`LICENSE`)
+56
View File
@@ -0,0 +1,56 @@
# Security Policy
PR Previews handles Gitea personal access tokens, AWS credentials, webhook
secrets, session cookies, SSH private keys, and live deployment logs. Treat
security issues as sensitive even when they appear limited to a local or
self-hosted deployment.
## Reporting a Vulnerability
Do not disclose suspected vulnerabilities publicly before they are reviewed.
Report issues through the private channel used by the project maintainers for
this repository. If you are running your own PP instance, report operational
incidents to that instance's administrator and rotate affected credentials.
Include:
- A short description of the issue and impact.
- Steps to reproduce, if safe to share.
- Affected versions, branches, or commit hashes.
- Whether any credentials, preview instances, repositories, or logs may have
been exposed.
## Supported Versions
Security fixes are made on the active development branch and the current stable
deployment branch. Older branches are not guaranteed to receive fixes unless a
maintainer explicitly backports them.
## Sensitive Data Rules
- Never commit `.env`, database dumps, private keys, access tokens, session
secrets, webhook secrets, or decrypted credential values.
- Never paste raw secrets into issues, pull requests, logs, screenshots, or PR
preview comments.
- Rotate Gitea PATs, AWS access keys, webhook secrets, and `SESSION_SECRET` if
exposure is suspected.
- Back up `ENCRYPTION_KEY` securely. Losing it makes stored credentials
unreadable; exposing it can expose encrypted secrets if the database is also
compromised.
## Security-Sensitive Areas
Review these areas carefully when changing behavior:
- `backend/src/lib/encryption.ts`
- `backend/src/lib/env.ts`
- `backend/src/lib/middlewares/auth.ts`
- `backend/src/routes/webhook.ts`
- `backend/src/services/deploy.ts`
- `backend/src/services/ec2.ts`
- `backend/src/services/gitea.ts`
- `backend/src/services/ssh.ts`
Preserve constant-time webhook signature checks, encrypted secrets at rest,
masked deploy logs, and API responses that avoid returning raw secrets.
+21 -28
View File
@@ -49,6 +49,8 @@ Per-repo settings, one per user per repo. A repo can only be configured by one u
- inactivityHours (float, default 12, range 0.572)
- port (int, default 3000 — the port the app listens on inside the EC2 instance)
- envVars (JSON key-value)
- preinstallTools (string[] — selected runtime/tooling bootstrap options: `docker`, `node`, `python`, `go`, `lua`, `build-essential`)
- nodeVersion (string, nullable — used when `node` is selected; default `lts/*`; `.nvmrc` in the repo wins during deploy)
- useDockerCompose (bool)
- composeFilePath (string, nullable — path within repo, default `docker-compose.yml`)
- aptPackages (string[])
@@ -90,7 +92,7 @@ Tracks which PRs have already received a "no previews configured" comment to avo
### AdminSettings
Single row global config (seeded on first run).
- id
- defaultInstanceType (string, default `t2.medium`)
- defaultInstanceType (string, default `t3.medium`)
- maxConcurrentInstancesPerUser (int, default 5)
- logSizeLimitBytes (int, default 1048576 — 1MB)
- previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged)
@@ -185,18 +187,9 @@ pp:previewId = <Preview.id>
```bash
#!/bin/bash
apt-get update -y
apt-get install -y curl git unzip build-essential
# Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
# Docker Compose v2 (plugin)
apt-get install -y docker-compose-plugin
# NVM + Node LTS
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default lts/*
apt-get install -y ca-certificates curl git unzip
```
- Repo-selected preinstall tools are installed over SSH after clone: Docker + Compose, Node via nvm, Python, Go, Lua, build tools, and custom apt packages.
- All instance tags as above.
4. Poll `DescribeInstances` until status = `running` and the instance has a public IP.
5. Poll TCP port 22 until it accepts connections (max 5 minutes, then fail).
@@ -219,33 +212,32 @@ Set Preview status to `PROVISIONING`.
1. Check `AdminSettings.maxConcurrentInstancesPerUser` — count PROVISIONING + BUILDING + RUNNING previews for this user. If at limit: post a Gitea comment on the PR explaining the limit is reached, mark job FAILED, stop.
2. **Provision EC2** — as described above.
3. SSH in. All subsequent commands run over SSH.
4. **Install apt packages** (if any): `sudo apt-get install -y <aptPackages>`. Only runs on first provision.
5. **Clone repo:**
4. **Clone repo:**
- Normal PR: `git clone https://<giteaUsername>:<PAT>@<giteaInstanceUrl>/<owner>/<repo>.git /opt/app`
- Fork PR: clone from the fork's URL (`payload.pull_request.head.repo.clone_url`, injecting PAT auth).
- `cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr`
6. **Detect Node version:** Check for `/opt/app/.nvmrc`. If present: `nvm install && nvm use`. Otherwise: `nvm use default`.
7. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`.
8. **Setup commands** (if any): run each in order. Only runs on first provision.
9. Set Preview status to `BUILDING`.
10. **Build commands:** run each in order.
11. **Post-build commands** (if any): run each in order.
12. **Start:**
- Docker Compose: `cd /opt/app && docker compose -f <composeFilePath> up -d --build --force-recreate`
5. **Install selected preinstall tools and apt packages:** Docker + Compose, Node (using `.nvmrc` if present, otherwise `RepoConfig.nodeVersion`), Python, Go, Lua, build tools, and custom `RepoConfig.aptPackages`.
6. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`.
7. **Setup commands** (if any): run each in order. Only runs on first provision.
8. Set Preview status to `BUILDING`.
9. **Build commands:** run each in order.
10. **Post-build commands** (if any): run each in order.
11. **Start:**
- Docker Compose: `cd /opt/app && sudo docker compose -f <composeFilePath> up -d --build --force-recreate`
- Non-compose: run `runCommand` in background via `nohup ... > /opt/app/pp.log 2>&1 &`, capture PID → save to `Preview.pid`.
13. Set Preview status to `RUNNING`. Store `instanceIp`, `port`, `commitSha`, `lastActivityAt = now`.
14. **Comment on PR** — post initial status comment (see PR Commenting section). Store the comment ID in `Preview.giteaCommentId`.
12. Set Preview status to `RUNNING`. Store `instanceIp`, `port`, `commitSha`, `lastActivityAt = now`.
13. **Comment on PR** — post initial status comment (see PR Commenting section). Store the comment ID in `Preview.giteaCommentId`.
#### Subsequent push (synchronize / existing RUNNING or FAILED Preview)
1. SSH into existing instance using `Preview.instanceIp` and decrypted `Preview.sshPrivateKey`.
2. **Stop current process:**
- Non-compose: `kill <Preview.pid>` (SIGTERM, wait 5s, SIGKILL if still running).
- Docker Compose: `cd /opt/app && docker compose -f <composeFilePath> down`.
- Docker Compose: `cd /opt/app && sudo docker compose -f <composeFilePath> down`.
3. **Pull latest:**
```bash
cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD
```
4. **Re-detect Node version** (`.nvmrc` may have changed).
4. **Re-run selected preinstall checks** (`.nvmrc` or runtime selections may have changed).
5. **Overwrite `.env`** — re-write from current `RepoConfig.envVars`.
6. Set Preview status to `BUILDING`. Append `\n--- Redeploy: <commitSha> ---\n` to `Preview.logs`.
7. **Build commands** — re-run each.
@@ -348,11 +340,12 @@ Note displayed in settings: _"PP will post PR comments as your Gitea account (@u
- A repo already configured by another PP user shows a lock icon and "Claimed by another user" — the toggle is disabled.
- Expanded per-repo config:
- **Deny list** — tag input of Gitea usernames to skip.
- **EC2 instance type** — searchable select (t2.micro, t2.medium, t3.medium, t3.large, m5.large, c5.large) + custom text input. Show estimated hourly cost next to each option.
- **EC2 instance type** — searchable select (t3.medium, t3.large, t3a.medium, t3a.large, t4g.medium, t4g.large, m5.large, c5.large) + custom text input. Show estimated hourly cost next to each option.
- **Inactivity kill timer** — slider 0.5h to 72h with labelled stops.
- **App port** — number input (default 3000).
- **Environment variables** — key/value editor, values masked. Add/remove rows.
- **Docker Compose toggle** — if on: show compose file path input (default `docker-compose.yml`), hide manual command fields.
- **Preinstall options** — checkboxes for Docker + Compose, Node.js (with version), Python, Go, Lua, build tools, plus custom apt packages.
- **Docker Compose toggle** — if on: require Docker, show compose file path input (default `docker-compose.yml`), hide manual command fields.
- **Commands** (shown for both modes unless noted):
- Apt packages — tag input (compose + non-compose)
- Setup commands — ordered list, add/remove/reorder (compose + non-compose, run once on first provision)
+77
View File
@@ -0,0 +1,77 @@
## Logic Ish, General TODOS
- [x] auto installed webhooks are "unnamed" — now named "PR Previews" on registration;
> boot reconciliation (services/webhookReconcile.ts) backfills the name and adds the missing pull_request_sync event onto existing webhooks
- [x] move docker, node, etc to "preinstall" options where you can select from "docker" (compose included ofc), "node" (even with a specified version), python, go, lua & some basic apt packages. The user can choose with a checkbox which ones they want to install. This will allow for more flexibility in the future and allow for more languages to be supported. Remove that everything is based on nodejs, its dynamic to a ton of languages and frameworks, not just nodejs.
- [x] when starting a preview from a stopped or deleted state, it should clear the logs from before.
- [x] better logs with timestamps
- [ ] openapi docs for the api based on `../shsf`'s implementation
- [x] add a "rebuild" button to the preview page that does a /pp rebuild
- [x] cost tracking
- Per Preview (visible in the ui)
- User Total
- Repo Total
- Instance Total
> Ensure that the cost tracking is accurate and reflects the actual costs. May require AWS integration to get accurate billing data
- add to stats dashboard
> Done via uptime × on-demand hourly rate (lib/cost.ts), not AWS billing APIs (those lag hours-to-a-day + need extra IAM). Preview now tracks instanceType/instanceLaunchedAt/accumulatedCostUsd; a cost session opens on launch (firstDeploy) and finalizes on stopPreview. Surfaced per-preview (list + detail), user total (Previews page), repo total (Repos page), and instance-wide total + live $/hr burn (Overview /api/stats + admin previews).
- [x] As gitea admin i see every repo on the instance, that is NOT good, i should only see the repos that i own or that i am a collaborator on.
## (Remaining) Tests
- [x] test other languages (python)
- [x] check if ec2 timeout kill works (!!!)
## UI Related
- [x] ability to by default hide old previews and have them be in a seperate list, aswell as not showing stopped ones.
- [x] Actual homepage with instance stats (total previews, total users, total repos, total instances, total cost, etc) (requires backend changes)
> New "Overview" homepage at `/` (previews list moved to `/previews`). Global instance-wide stats via GET /api/stats: totalUsers, totalRepos/enabledRepos, totalPreviews/activePreviews, activeInstances (live EC2s), status breakdown + recent activity. Cost omitted — cost tracking is still an open TODO.
- [x] Search bar and filters for public & private repos in /repos
- [x] move repo config into its own page with a "configure" button on /repos
- [x] key-value inputs are seperately sized, bad
- [x] "apt packages" field does not support advertised "space separated" input, i can not press the spacebar
- [x] "configure" button on /repos to configure a repo not just a goofy toggle.
- [x] in light mode code blocks are still dark, make them light mode friendly
- [x] better light/dark mode toggle
- [x] use "professional" icons for the ui instead of the unicode ones.
- [x] use icon from "./icon.png" & move it around & scale with cli for favicon and all types of icons needed for the seo in the ui
- [ ] Fix Theming to be more professional (use frontend themeing skill & make it look like a professional product)
- [x] "save config" should not return me to the repo list...
- [x] sort repos list by configured first, then alphabetically (repo name, not owner. group owner repos sperately, then sort by repo name)
## / Command related
- [x] /pp rebuild doesnt comment, nor do anything on a Stopped preview
> rebuild now posts an ack comment and, on a STOPPED preview, re-provisions a fresh instance (isFirstDeploy). All commands post acknowledgement comments (webhook.ts `ack()`).
- [x] add "/pp help"
> `/pp help` posts a command reference table (webhook.ts `buildHelpBody`), striking through commands disabled for the repo.
- [x] pp stop returns no comment
> stop (and every other command) now posts an ack comment.
- [x] user configs /pp commands
> New `RepoConfig.disabledCommands` field (migration 20260726120000_pp_command_config) + checkboxes on the Repo Config page. Disabled commands reply "disabled for this repository"; `/pp help` can never be disabled.
## Backend Related
- [x] switch away from t2.medium ASAP (and its prefill configs, make the default a t4a.medium or t3.medium, and make it configurable in the ui, no more t2s and by default migrate all existing t2.mediums to t4a.mediums or t3.mediums) (dont forget cost calculation changes)
> Default moved to `t3.medium`; repo/admin settings now normalize `t2.*` to `t3.medium`; migration updates existing `RepoConfig` and `AdminSettings` rows/defaults. UI presets no longer include t2 and now show current-generation t3/t3a/t4g options with updated cost estimates. Historical t2 cost rates are retained only for already-launched preview cost snapshots.
- [x] ensure when i rotate credentials for a webhook it auto updates all repos webhooks accordingly
> Gitea credential saves and webhook secret regeneration now sync every enabled stored Gitea hook with the current target URL, secret, name, active state, and required PP events.
- [ ] split up the codebase more into smaller files, i see a lot of big files
- [x] live app logs from app processes (not just the pp process)
> New on-demand SSH tail streamed over a separate WS (`/api/previews/{id}/applogs`, services/appLogs.ts + ssh.ts execStream). Non-compose tails /opt/app/pp.log (`tail -F`); compose uses `docker compose logs -f`. Surfaced as a Deploy/App tab toggle on the preview page — not persisted, live only while RUNNING.
## Other (Comments, Gitea, etc)
- [x] "Status: ⚫ Stopped (inactivity timeout / PR closed / manual stop)" Add a propper reason field for the state
## Final Pre-Prod
- [x] A bunch of meta tags for the ui for seo and social media sharing, etc
- [x] Decrease Size of Docker Image lmao & increase build speed !!
- [x] Update Privacy Policy to be more professional and less "i made this in 2 days" and more "this is a professional product"
- [x] CI/CD is STILL missing..... (spec)
> Added Gitea Actions workflow at `.gitea/workflows/deploy.yml`: pinned pnpm install, Prisma generate, backend/frontend builds, Docker build on PRs, and Harbor push to `registry.reversed.dev/pp-previews/core` on `main`/`dev` pushes.
- [x] Human understandable Readme
- [x] LICENSE, CONTRIBUTING, SECURITY.md, etc
## Future BS
- [ ] CPU, MEM, NET, DISK sentinal first-installed on EC2s to expose a backend for the user's ui to hit, THROUGH A BACKEND PROXY ROUTE so we can cache and rate limit the requests. This will allow for a better dashboard and better stats for the user to see. We'll use an obscure port for the sentinal so that we dont hit any other services the user may have. Sentinal is our own little C program exposing the stats we need, seperate repo for that thing tho!
+14 -11
View File
@@ -7,6 +7,7 @@
"scripts": {
"dev": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist && cd dist && node index.js",
"build": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist",
"build:prod": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --ignore-annotations --format=cjs --target=es2022 --outdir=dist",
"start": "cd dist && node index.js",
"prod": "pnpm build && pnpm start",
"migrate": "prisma migrate dev --schema=../prisma/schema.prisma",
@@ -19,23 +20,25 @@
"@prisma/client": "^6.0.0",
"@rjweb/runtime-node": "^1.1.1",
"@rjweb/utils": "^1.12.29",
"axios": "^1.7.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.0.0",
"node-cron": "^3.0.3",
"pino": "^10.0.0",
"prisma": "^6.0.0",
"rjweb-server": "^9.8.6",
"ssh2": "^1.16.0",
"ws": "^8.18.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.0.0",
"@types/ssh2": "^1.15.0",
"@types/ws": "^8.5.0",
"axios": "^1.7.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.0.0",
"esbuild": "^0.25.0",
"node-cron": "^3.0.3",
"pino": "^10.0.0",
"pino-pretty": "^13.0.0",
"prisma": "^6.0.0",
"rimraf": "^5.0.0",
"rjweb-server": "^9.8.6",
"ssh2": "^1.16.0",
"typescript": "^5.0.0",
"ws": "^8.18.0",
"zod": "^3.24.0"
"typescript": "^5.0.0"
}
}
+15 -1
View File
@@ -14,12 +14,14 @@ import { startJobWorker } from "./workers/jobWorker";
import { startCronWorkers } from "./workers/cronWorker";
import { getAdminSettings } from "./lib/adminSettings";
import { runOrphanCleanup } from "./services/orphanCleanup";
import { runWebhookReconciliation } from "./services/webhookReconcile";
import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth";
import { webhookHandler } from "./routes/webhook";
import { getUserSettings, updateUsername, updatePassword, updateGitea, updateAws, getWebhookSecret, regenerateWebhookSecret } from "./routes/api/user";
import { listRepos, saveRepoConfig, toggleRepoEnabled, getRepoConfig } from "./routes/api/repos";
import { listPreviews, getPreview, stopPreviewRoute, previewLogsWs } from "./routes/api/previews";
import { listPreviews, getPreview, stopPreviewRoute, rebuildPreviewRoute, previewLogsWs, previewAppLogsWs } from "./routes/api/previews";
import { getStats } from "./routes/api/stats";
import { listUsers, createUser, updateUser, deleteUser, getSettings, updateSettings, adminListPreviews, adminStopPreview } from "./routes/api/admin";
const uiBuildPath = join(__dirname, "../../frontend/dist");
@@ -76,16 +78,27 @@ server.path("/", (path) => path
.http("GET", "/api/repos/{owner}/{repo}/config", (http) => http.onRequest(getRepoConfig))
);
// Stats (instance-wide Overview)
server.path("/", (path) => path
.http("GET", "/api/stats", (http) => http.onRequest(getStats))
);
// Previews
server.path("/", (path) => path
.http("GET", "/api/previews", (http) => http.onRequest(listPreviews))
.http("GET", "/api/previews/{id}", (http) => http.onRequest(getPreview))
.http("POST", "/api/previews/{id}/stop", (http) => http.onRequest(stopPreviewRoute))
.http("POST", "/api/previews/{id}/rebuild", (http) => http.onRequest(rebuildPreviewRoute))
.ws("/api/previews/{id}/logs", (ws) => ws
.onOpen(previewLogsWs)
.onMessage(async () => {})
.onClose(async () => {})
)
.ws("/api/previews/{id}/applogs", (ws) => ws
.onOpen(previewAppLogsWs)
.onMessage(async () => {})
.onClose(async () => {})
)
);
// Admin
@@ -127,6 +140,7 @@ server
startJobWorker();
startCronWorkers();
runOrphanCleanup().catch(e => logger.warn(e, "Orphan cleanup error"));
runWebhookReconciliation().catch(e => logger.warn(e, "Webhook reconciliation error"));
logger.info("All workers started");
})
.catch((err) => logger.error(err, "Server failed to start"));
+2 -1
View File
@@ -1,9 +1,10 @@
import { prisma } from "./db";
import { DEFAULT_INSTANCE_TYPE } from "./instanceTypes";
export async function getAdminSettings() {
let settings = await prisma.adminSettings.findUnique({ where: { id: 1 } });
if (!settings) {
settings = await prisma.adminSettings.create({ data: { id: 1 } });
settings = await prisma.adminSettings.create({ data: { id: 1, defaultInstanceType: DEFAULT_INSTANCE_TYPE } });
}
return settings;
}
+71
View File
@@ -0,0 +1,71 @@
import { DEFAULT_INSTANCE_TYPE } from "./instanceTypes";
// Cost estimation for preview EC2 instances.
//
// PP bills nothing itself; the EC2 runs in the user's own AWS account, so this
// is an estimate derived from on-demand pricing and how long each instance ran.
// It is deliberately not tied to AWS Cost Explorer / billing APIs: those lag by
// hours-to-a-day and would need extra IAM permissions. Uptime times hourly rate
// is accurate enough for a dashboard and updates live.
//
// Rates below are USD/hour for Linux on-demand in us-east-1. Other regions cost
// a little more, so treat the number as a lower-bound ballpark. Keep these in
// sync with the table shown in frontend RepoConfig.tsx.
export const INSTANCE_HOURLY_USD: Record<string, number> = {
// Legacy t2 rates are retained for historical previews that were actually
// launched as t2 before the default moved to current-generation instances.
"t2.micro": 0.0116,
"t2.small": 0.023,
"t2.medium": 0.0464,
"t2.large": 0.0928,
"t3.micro": 0.0104,
"t3.small": 0.0208,
"t3.medium": 0.0416,
"t3.large": 0.0832,
"t3a.medium": 0.0376,
"t3a.large": 0.0752,
"t4g.medium": 0.0336,
"t4g.large": 0.0672,
"m5.large": 0.096,
"m5.xlarge": 0.192,
"c5.large": 0.085,
"c5.xlarge": 0.17,
};
// Fallback when the instance type is unknown/custom: use the current default so
// an estimate is still shown rather than $0.
const DEFAULT_HOURLY_USD = INSTANCE_HOURLY_USD[DEFAULT_INSTANCE_TYPE];
export function hourlyRateUsd(instanceType?: string | null): number {
if (!instanceType) return DEFAULT_HOURLY_USD;
return INSTANCE_HOURLY_USD[instanceType] ?? DEFAULT_HOURLY_USD;
}
// Cost of a single instance session from launch to now/termination at the given
// type's hourly rate. Clamps negatives to 0 in case clocks disagree.
export function sessionCostUsd(
launchedAt: Date,
until: Date,
instanceType?: string | null,
): number {
const hours = Math.max(0, (until.getTime() - launchedAt.getTime()) / 3_600_000);
return hours * hourlyRateUsd(instanceType);
}
// The fields cost calculation needs off a Preview row.
export interface PreviewCostFields {
accumulatedCostUsd: number;
instanceLaunchedAt: Date | null;
instanceType: string | null;
}
// Total estimated cost for a preview: finalized cost of past sessions plus the
// live session's accrual so far, if an instance is currently running.
export function computePreviewCostUsd(p: PreviewCostFields, now: Date = new Date()): number {
let total = p.accumulatedCostUsd || 0;
if (p.instanceLaunchedAt) {
total += sessionCostUsd(p.instanceLaunchedAt, now, p.instanceType);
}
return total;
}
+12
View File
@@ -0,0 +1,12 @@
export const DEFAULT_INSTANCE_TYPE = "t3.medium";
export const LEGACY_INSTANCE_TYPE_REPLACEMENTS: Record<string, string> = {
"t2.medium": DEFAULT_INSTANCE_TYPE,
};
export function normalizeInstanceType(value?: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
if (!raw) return DEFAULT_INSTANCE_TYPE;
if (raw.startsWith("t2.")) return LEGACY_INSTANCE_TYPE_REPLACEMENTS[raw] ?? DEFAULT_INSTANCE_TYPE;
return raw.slice(0, 64);
}
+35
View File
@@ -81,3 +81,38 @@ export const authEnforcementMiddleware = new Middleware<{}, {}>(
.export();
export const COOKIE_NAME_EXPORT = COOKIE_NAME;
// The auth-resolution middleware only runs on HTTP requests (`.httpRequest` /
// `.httpRequestContext`), so `ctr.getAuth()` is never populated on a WebSocket
// upgrade — every WS would see `success: false` and close 1008. WebSocket
// handlers must resolve the session themselves; this mirrors the middleware's
// logic. Reads the session cookie via the ctr cookie API, falling back to
// parsing the raw `Cookie` header from the upgrade request.
export async function resolveWsAuth(ctr: any): Promise<AuthState> {
let cookieToken: string | undefined;
try {
cookieToken = ctr.cookies?.get?.(COOKIE_NAME);
} catch {
// fall through to header parsing
}
if (!cookieToken) {
const raw = (ctr.headers?.get?.("cookie") as string | undefined) || "";
const match = raw.match(/(?:^|;\s*)pp_session=([^;]+)/);
if (match) cookieToken = decodeURIComponent(match[1]);
}
if (!cookieToken) {
return { success: false, message: "No session", tokenProvided: false };
}
const session = await prisma.session.findFirst({
where: { hash: cookieToken },
include: { user: true },
});
if (!session) {
return { success: false, message: "Invalid session", tokenProvided: true };
}
return { success: true, user: session.user, sessionId: session.id };
}
+28
View File
@@ -0,0 +1,28 @@
export const PREINSTALL_TOOLS = ["docker", "node", "python", "go", "lua", "build-essential"] as const;
export type PreinstallTool = typeof PREINSTALL_TOOLS[number];
const toolSet = new Set<string>(PREINSTALL_TOOLS);
const APT_PACKAGE_RE = /^[A-Za-z0-9.+:-]+$/;
export function normalizePreinstallTools(input: unknown, useDockerCompose = false): PreinstallTool[] {
const tools = Array.isArray(input)
? input.filter((tool): tool is PreinstallTool => typeof tool === "string" && toolSet.has(tool))
: [];
if (useDockerCompose && !tools.includes("docker")) tools.push("docker");
return [...new Set(tools)];
}
export function normalizeNodeVersion(input: unknown, nodeSelected: boolean): string | null {
if (!nodeSelected) return null;
const value = typeof input === "string" ? input.trim() : "";
if (!value) return "lts/*";
return /^[A-Za-z0-9._/*+-]+$/.test(value) ? value.slice(0, 64) : "lts/*";
}
export function normalizeAptPackages(input: unknown): string[] {
const packages = Array.isArray(input)
? input.filter((pkg): pkg is string => typeof pkg === "string" && APT_PACKAGE_RE.test(pkg))
: [];
return [...new Set(packages)];
}
+6 -1
View File
@@ -4,6 +4,8 @@ import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { getAdminSettings } from "../../lib/adminSettings";
import { computePreviewCostUsd } from "../../lib/cost";
import { normalizeInstanceType } from "../../lib/instanceTypes";
function requireAdmin(ctr: any) {
const auth = ctr.getAuth?.();
@@ -112,7 +114,7 @@ export async function updateSettings(ctr: any) {
} = body || {};
const data: any = {};
if (defaultInstanceType) data.defaultInstanceType = defaultInstanceType;
if (defaultInstanceType !== undefined) data.defaultInstanceType = normalizeInstanceType(defaultInstanceType);
if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser);
if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes);
if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays);
@@ -147,6 +149,7 @@ export async function adminListPreviews(ctr: any) {
prNumber: p.prNumber,
prTitle: p.prTitle,
status: p.status,
stopReason: p.stopReason,
instanceIp: p.instanceIp,
port: p.port,
createdAt: p.createdAt,
@@ -154,6 +157,8 @@ export async function adminListPreviews(ctr: any) {
repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName,
user: (p.repoConfig as any).user,
instanceType: p.instanceType,
costUsd: computePreviewCostUsd(p),
}))
}
});
+100 -3
View File
@@ -2,6 +2,10 @@ import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { subscribeToLogs } from "../../services/deploy";
import { streamAppLogs } from "../../services/appLogs";
import { resolveWsAuth } from "../../lib/middlewares/auth";
import { computePreviewCostUsd, hourlyRateUsd } from "../../lib/cost";
import { fetchRepo } from "../../services/gitea";
function requireAuth(ctr: any) {
const auth = ctr.getAuth?.();
@@ -27,6 +31,7 @@ export async function listPreviews(ctr: any) {
prTitle: p.prTitle,
commitSha: p.commitSha,
status: p.status,
stopReason: p.stopReason,
instanceIp: p.instanceIp,
port: p.port,
createdAt: p.createdAt,
@@ -34,6 +39,8 @@ export async function listPreviews(ctr: any) {
lastActivityAt: p.lastActivityAt,
repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName,
instanceType: p.instanceType,
costUsd: computePreviewCostUsd(p),
}))
}
});
@@ -62,6 +69,7 @@ export async function getPreview(ctr: any) {
prTitle: preview.prTitle,
commitSha: preview.commitSha,
status: preview.status,
stopReason: preview.stopReason,
instanceIp: preview.instanceIp,
port: preview.port,
logs: preview.logs,
@@ -71,6 +79,9 @@ export async function getPreview(ctr: any) {
lastActivityAt: preview.lastActivityAt,
repoOwner: preview.repoConfig.repoOwner,
repoName: preview.repoConfig.repoName,
instanceType: preview.instanceType,
costUsd: computePreviewCostUsd(preview),
costRateUsd: preview.instanceLaunchedAt ? hourlyRateUsd(preview.instanceType) : 0,
jobs: preview.jobs.map(j => ({
id: j.id,
type: j.type,
@@ -104,8 +115,62 @@ export async function stopPreviewRoute(ctr: any) {
return makeResponse({ ctr, content: { code: 200, message: "Stop job enqueued" } });
}
export async function rebuildPreviewRoute(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const id = parseInt(ctr.params.get("id") || "0", 10);
const preview = await prisma.preview.findFirst({
where: { id, repoConfig: { userId: user.id } },
include: { repoConfig: { include: { user: true } } },
});
if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });
if (preview.status === "IGNORED") {
return makeResponse({ ctr, content: { code: 400, message: "Ignored previews cannot be rebuilt" } });
}
const fullUser = preview.repoConfig.user;
if (!fullUser.giteaInstanceUrl || !fullUser.giteaPAT) {
return makeResponse({ ctr, content: { code: 400, message: "Gitea credentials not configured" } });
}
const repo = await fetchRepo(fullUser as any, preview.repoConfig.repoOwner, preview.repoConfig.repoName);
const cloneUrl = repo.clone_url;
if (!cloneUrl) {
return makeResponse({ ctr, content: { code: 400, message: "Gitea repo clone URL not found" } });
}
const isFirstDeploy = preview.status === "STOPPED";
if (isFirstDeploy) {
await prisma.preview.update({
where: { id: preview.id },
data: { status: "PROVISIONING", stopReason: null, stoppedAt: null, lastActivityAt: new Date() },
});
} else {
await prisma.preview.update({ where: { id: preview.id }, data: { lastActivityAt: new Date() } });
}
await prisma.job.create({
data: {
previewId: preview.id,
type: "DEPLOY",
status: "PENDING",
payload: {
commitSha: preview.commitSha,
prNumber: preview.prNumber,
prTitle: preview.prTitle,
cloneUrl,
isFirstDeploy,
},
},
});
return makeResponse({ ctr, content: { code: 200, message: "Rebuild job enqueued" } });
}
export async function previewLogsWs(ctr: any) {
const auth = ctr.getAuth?.();
const auth = await resolveWsAuth(ctr);
if (!auth?.success) {
ctr.close(1008, "Unauthorized");
return;
@@ -121,13 +186,45 @@ export async function previewLogsWs(ctr: any) {
return;
}
await ctr.print(JSON.stringify({ type: "init", logs: preview.logs }));
// rjweb's WS print signature is print(type, content) — content objects are
// JSON-serialized for us. Passing a single stringified arg puts the JSON in
// the `type` slot and sends an empty frame, so always use ("text", obj).
await ctr.print("text", { type: "init", logs: preview.logs });
const unsub = subscribeToLogs(id, (text) => {
try {
ctr.print(JSON.stringify({ type: "append", text }));
ctr.print("text", { type: "append", text });
} catch {}
});
ctr.$abort(unsub);
}
// Live stream of the running app's OWN stdout/stderr (not the PP deploy log).
// Opens a dedicated SSH tail against the instance for the duration of the
// connection; nothing is persisted. Only available while the preview is live.
export async function previewAppLogsWs(ctr: any) {
const auth = await resolveWsAuth(ctr);
if (!auth?.success) {
ctr.close(1008, "Unauthorized");
return;
}
const id = parseInt(ctr.params.get("id") || "0", 10);
const preview = await prisma.preview.findFirst({
where: { id, repoConfig: { userId: auth.user.id } },
});
if (!preview) {
ctr.close(1008, "Not found");
return;
}
const stop = await streamAppLogs(id, (text) => {
try {
ctr.print("text", { type: "append", text });
} catch {}
});
ctr.$abort(stop);
}
+33 -3
View File
@@ -4,6 +4,9 @@ import { ERROR_MESSAGES } from "../../lib/errors";
import { fetchUserRepos, registerWebhook, deleteWebhook } from "../../services/gitea";
import { getAdminSettings } from "../../lib/adminSettings";
import { env } from "../../lib/env";
import { computePreviewCostUsd } from "../../lib/cost";
import { normalizeInstanceType } from "../../lib/instanceTypes";
import { normalizeAptPackages, normalizeNodeVersion, normalizePreinstallTools } from "../../lib/preinstall";
function requireAuth(ctr: any) {
const auth = ctr.getAuth?.();
@@ -24,6 +27,16 @@ export async function listRepos(ctr: any) {
const configs = await prisma.repoConfig.findMany({ where: { userId: user.id } });
const configMap = new Map(configs.map(c => [`${c.repoOwner}/${c.repoName}`, c]));
// Estimated total EC2 cost per repo — sum over that repo's previews. See lib/cost.ts.
const costPreviews = await prisma.preview.findMany({
where: { repoConfig: { userId: user.id } },
select: { repoConfigId: true, accumulatedCostUsd: true, instanceLaunchedAt: true, instanceType: true },
});
const costByConfigId = new Map<number, number>();
for (const p of costPreviews) {
costByConfigId.set(p.repoConfigId, (costByConfigId.get(p.repoConfigId) ?? 0) + computePreviewCostUsd(p));
}
const allOwners = [...new Set(giteaRepos.map((r: any) => r.full_name?.split("/")[0]).filter(Boolean))];
const allConfigs = await prisma.repoConfig.findMany({
where: { repoOwner: { in: allOwners } },
@@ -43,10 +56,20 @@ export async function listRepos(ctr: any) {
name,
fullName: r.full_name,
htmlUrl: r.html_url,
isPrivate: Boolean(r.private),
isEnabled: config?.isEnabled ?? false,
claimedByOther: claimedByOthers.has(key),
config: config ? safeConfig : null,
costUsd: config ? (costByConfigId.get(config.id) ?? 0) : 0,
};
}).sort((a: any, b: any) => {
const configuredDelta = Number(Boolean(b.config)) - Number(Boolean(a.config));
if (configuredDelta !== 0) return configuredDelta;
const ownerDelta = a.owner.localeCompare(b.owner, undefined, { sensitivity: "base", numeric: true });
if (ownerDelta !== 0) return ownerDelta;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
});
return makeResponse({ ctr, content: { code: 200, data: result } });
@@ -86,22 +109,29 @@ export async function saveRepoConfig(ctr: any) {
const settings = await getAdminSettings();
const useDockerCompose = Boolean(configData.useDockerCompose ?? false);
const preinstallTools = normalizePreinstallTools(configData.preinstallTools, useDockerCompose);
const sanitized = {
repoOwner: owner,
repoName: repo,
userId: user.id,
instanceType: configData.instanceType ?? settings.defaultInstanceType,
instanceType: normalizeInstanceType(configData.instanceType ?? settings.defaultInstanceType),
inactivityHours: Math.min(72, Math.max(0.5, Number(configData.inactivityHours ?? 12))),
port: Number(configData.port ?? 3000),
envVars: configData.envVars ?? {},
useDockerCompose: Boolean(configData.useDockerCompose ?? false),
preinstallTools,
nodeVersion: normalizeNodeVersion(configData.nodeVersion, preinstallTools.includes("node")),
useDockerCompose,
composeFilePath: configData.composeFilePath ?? null,
aptPackages: Array.isArray(configData.aptPackages) ? configData.aptPackages : [],
aptPackages: normalizeAptPackages(configData.aptPackages),
setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [],
buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [],
postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [],
runCommand: configData.runCommand ?? null,
denyList: Array.isArray(configData.denyList) ? configData.denyList : [],
disabledCommands: Array.isArray(configData.disabledCommands)
? configData.disabledCommands.filter((c: unknown): c is string => typeof c === "string" && c !== "help")
: [],
};
let config;
+91
View File
@@ -0,0 +1,91 @@
import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { computePreviewCostUsd, hourlyRateUsd } from "../../lib/cost";
import { PreviewStatus } from "@prisma/client";
function requireAuth(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) return null;
return auth.user;
}
const ACTIVE_STATUSES: PreviewStatus[] = ["PROVISIONING", "BUILDING", "RUNNING"];
// Instance-wide ("global") stats for the Overview homepage.
export async function getStats(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const [
totalUsers,
totalRepos,
enabledRepos,
totalPreviews,
activePreviews,
activeInstances,
byStatusRaw,
recentRaw,
costRaw,
] = await Promise.all([
prisma.user.count(),
prisma.repoConfig.count(),
prisma.repoConfig.count({ where: { isEnabled: true } }),
prisma.preview.count(),
prisma.preview.count({ where: { status: { in: ACTIVE_STATUSES } } }),
// A live EC2 instance is any preview still holding an instanceId.
prisma.preview.count({ where: { instanceId: { not: null } } }),
prisma.preview.groupBy({ by: ["status"], _count: { _all: true } }),
prisma.preview.findMany({
orderBy: { updatedAt: "desc" },
take: 8,
include: { repoConfig: { select: { repoOwner: true, repoName: true } } },
}),
prisma.preview.findMany({
select: { accumulatedCostUsd: true, instanceLaunchedAt: true, instanceType: true },
}),
]);
const byStatus: Record<string, number> = {};
for (const row of byStatusRaw) byStatus[row.status] = row._count._all;
// Instance-wide (this PP deployment) estimated EC2 spend, and the current
// hourly burn rate across every live instance. See lib/cost.ts.
const now = new Date();
let totalCostUsd = 0;
let hourlyBurnUsd = 0;
for (const p of costRaw) {
totalCostUsd += computePreviewCostUsd(p, now);
if (p.instanceLaunchedAt) hourlyBurnUsd += hourlyRateUsd(p.instanceType);
}
return makeResponse({
ctr,
content: {
code: 200,
data: {
totalUsers,
totalRepos,
enabledRepos,
totalPreviews,
activePreviews,
activeInstances,
totalCostUsd,
hourlyBurnUsd,
byStatus,
recentPreviews: recentRaw.map(p => ({
id: p.id,
prNumber: p.prNumber,
prTitle: p.prTitle,
status: p.status,
stopReason: p.stopReason,
instanceIp: p.instanceIp,
port: p.port,
updatedAt: p.updatedAt,
repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName,
})),
},
},
});
}
+64 -28
View File
@@ -4,7 +4,7 @@ import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { encrypt, decrypt } from "../../lib/encryption";
import { validateGiteaUrl } from "../../services/gitea";
import { updateWebhookSecret, validateGiteaUrl, validateGiteaToken } from "../../services/gitea";
import { validateAwsCredentials } from "../../services/ec2";
import { env } from "../../lib/env";
@@ -14,6 +14,39 @@ function requireAuth(ctr: any) {
return auth.user;
}
async function getOrCreateWebhookToken(userId: number) {
const existing = await prisma.webhookToken.findUnique({ where: { userId } });
if (existing) return existing;
const secret = randomBytes(32).toString("hex");
return prisma.webhookToken.create({ data: { userId, token: secret } });
}
async function syncRegisteredRepoWebhooks(userId: number, fullUser: any, secret: string) {
if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) {
return { updated: 0, errors: [] as string[] };
}
const configs = await prisma.repoConfig.findMany({
where: { userId, giteaWebhookId: { not: null } },
});
const webhookUrl = `${env.PP_BASE_URL}/webhook/${userId}`;
const errors: string[] = [];
let updated = 0;
for (const config of configs) {
try {
await updateWebhookSecret(fullUser, config.repoOwner, config.repoName, config.giteaWebhookId!, webhookUrl, secret);
updated++;
} catch (e: any) {
errors.push(`${config.repoOwner}/${config.repoName}: ${e.message}`);
}
}
return { updated, errors };
}
export async function getUserSettings(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
@@ -88,17 +121,38 @@ export async function updateGitea(ctr: any) {
}
const data: any = { giteaInstanceUrl: cleanUrl, giteaUsername };
if (giteaPAT) data.giteaPAT = encrypt(giteaPAT);
if (giteaPAT) {
const tokenCheck = await validateGiteaToken(cleanUrl, giteaPAT, giteaUsername);
if (!tokenCheck.success) {
return makeResponse({ ctr, content: { code: 400, message: `Gitea token rejected: ${tokenCheck.error}` } });
}
data.giteaPAT = encrypt(giteaPAT);
}
await prisma.user.update({ where: { id: user.id }, data });
return makeResponse({ ctr, content: { code: 200, message: `Connected to Gitea ${validation.version}`, data: { version: validation.version } } });
const updatedUser = await prisma.user.update({ where: { id: user.id }, data });
const webhookToken = await getOrCreateWebhookToken(user.id);
const hookSync = await syncRegisteredRepoWebhooks(user.id, updatedUser, webhookToken.token);
return makeResponse({
ctr, content: {
code: 200,
message: hookSync.errors.length
? `Connected to Gitea ${validation.version}; ${hookSync.updated} webhooks updated, ${hookSync.errors.length} failed`
: hookSync.updated > 0
? `Connected to Gitea ${validation.version}; ${hookSync.updated} webhooks updated`
: `Connected to Gitea ${validation.version}`,
data: { version: validation.version, hookSync },
}
});
}
export async function updateAws(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { awsAccessKeyId, awsSecretAccessKey, awsRegion } = body || {};
const awsAccessKeyId = typeof body?.awsAccessKeyId === "string" ? body.awsAccessKeyId.trim() : body?.awsAccessKeyId;
const awsSecretAccessKey = typeof body?.awsSecretAccessKey === "string" ? body.awsSecretAccessKey.trim() : body?.awsSecretAccessKey;
const awsRegion = typeof body?.awsRegion === "string" ? body.awsRegion.trim() : body?.awsRegion;
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
return makeResponse({ ctr, content: { code: 400, message: "AWS credentials and region required" } });
@@ -131,13 +185,9 @@ export async function getWebhookSecret(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
let token = await prisma.webhookToken.findUnique({ where: { userId: user.id } });
if (!token) {
const secret = randomBytes(32).toString("hex");
token = await prisma.webhookToken.create({ data: { userId: user.id, token: secret } });
}
const token = await getOrCreateWebhookToken(user.id);
return makeResponse({ ctr, content: { code: 200, data: { token: token.token } } });
return makeResponse({ ctr, content: { code: 200, data: { token: token.token, webhookUrl: `${env.PP_BASE_URL}/webhook/${user.id}` } } });
}
export async function regenerateWebhookSecret(ctr: any) {
@@ -156,27 +206,13 @@ export async function regenerateWebhookSecret(ctr: any) {
return makeResponse({ ctr, content: { code: 200, message: "Secret regenerated (no Gitea hooks to update)", data: { token: newSecret } } });
}
const configs = await prisma.repoConfig.findMany({
where: { userId: user.id, giteaWebhookId: { not: null } },
});
const { updateWebhookSecret } = await import("../../services/gitea");
const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`;
const errors: string[] = [];
for (const config of configs) {
try {
await updateWebhookSecret(fullUser as any, config.repoOwner, config.repoName, config.giteaWebhookId!, webhookUrl, newSecret);
} catch (e: any) {
errors.push(`${config.repoOwner}/${config.repoName}: ${e.message}`);
}
}
const hookSync = await syncRegisteredRepoWebhooks(user.id, fullUser, newSecret);
return makeResponse({
ctr, content: {
code: 200,
message: errors.length ? `Secret regenerated with ${errors.length} hook update errors` : "Secret regenerated and all hooks updated",
data: { token: newSecret, errors },
message: hookSync.errors.length ? `Secret regenerated with ${hookSync.errors.length} hook update errors` : "Secret regenerated and all hooks updated",
data: { token: newSecret, errors: hookSync.errors, hookSync },
}
});
}
+92 -14
View File
@@ -133,7 +133,9 @@ async function handlePullRequestEvent(user: any, payload: any) {
},
});
}
} else if (action === "synchronize") {
} else if (action === "synchronized" || action === "synchronize") {
// Gitea sends "synchronized" (past tense) on a push to the PR branch;
// GitHub uses "synchronize". Accept both so the redeploy fires either way.
const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber },
orderBy: { createdAt: "desc" },
@@ -186,7 +188,12 @@ async function handleIssueCommentEvent(user: any, payload: any) {
const repoName = repo.name;
const prNumber = issue.number;
if (user.giteaUsername && comment.user?.login === user.giteaUsername) return;
// Note: we intentionally do NOT ignore comments authored by PP's own Gitea
// account here. Loop-prevention is already handled by the `/pp ` prefix check
// above — PP's own comments (status updates, `/pp logs` output) never start
// with `/pp `, so they can't re-trigger a command. Guarding on giteaUsername
// instead only blocked legitimate commands from operators who run PP under
// the same account they comment from.
const repoConfig = await prisma.repoConfig.findFirst({
where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },
@@ -207,8 +214,9 @@ async function handleIssueCommentEvent(user: any, payload: any) {
data: { lastActivityAt: new Date() },
});
// First token after "/pp " is the command word; everything else is ignored.
const commandLine = body.trimStart().split("\n")[0].trim();
const command = commandLine.replace("/pp ", "").trim();
const command = commandLine.replace(/^\/pp\s+/, "").trim().split(/\s+/)[0].toLowerCase();
const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber },
@@ -217,21 +225,45 @@ async function handleIssueCommentEvent(user: any, payload: any) {
const cloneUrl = repo.clone_url;
if (command === "rebuild") {
if (!preview || (preview.status !== "RUNNING" && preview.status !== "FAILED")) return;
// Operator-configured per-repo command switches. `help` can never be disabled
// so users always retain a way to discover what is (and isn't) available.
const disabled: string[] = Array.isArray(repoConfig.disabledCommands) ? repoConfig.disabledCommands : [];
if (command !== "help" && disabled.includes(command)) {
await ack(user, owner, repoName, prNumber, `⚠️ The \`/pp ${command}\` command is disabled for this repository.`);
return;
}
if (command === "help") {
await ack(user, owner, repoName, prNumber, buildHelpBody(disabled));
} else if (command === "rebuild") {
if (!preview) {
await ack(user, owner, repoName, prNumber, "️ No preview exists for this PR yet. Use `/pp start` to create one.");
return;
}
if (preview.status === "IGNORED") return;
if (preview.status === "STOPPED") {
// Nothing live to reuse — re-provision a fresh instance (first deploy).
await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING", stopReason: null, stoppedAt: null } });
await prisma.job.create({
data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } },
});
await ack(user, owner, repoName, prNumber, "🔄 Preview was stopped — re-provisioning a fresh instance...");
return;
}
// RUNNING / FAILED / BUILDING / PROVISIONING: rebuild on the existing instance.
await prisma.job.create({
data: {
previewId: preview.id,
type: "DEPLOY",
status: "PENDING",
payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false },
},
data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false } },
});
await ack(user, owner, repoName, prNumber, "🔄 Rebuilding preview on the existing instance...");
} else if (command === "stop") {
if (!preview || preview.status === "STOPPED") return;
if (!preview || preview.status === "STOPPED") {
await ack(user, owner, repoName, prNumber, "️ No running preview to stop.");
return;
}
await prisma.job.create({
data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } },
});
await ack(user, owner, repoName, prNumber, "🛑 Stopping preview...");
} else if (command === "start") {
if (!preview) {
const newPreview = await prisma.preview.create({
@@ -240,14 +272,21 @@ async function handleIssueCommentEvent(user: any, payload: any) {
await prisma.job.create({
data: { previewId: newPreview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: "", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } },
});
await ack(user, owner, repoName, prNumber, "🚀 Starting preview...");
} else if (preview.status === "STOPPED" || preview.status === "IGNORED") {
await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING" } });
await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING", stopReason: null, stoppedAt: null } });
await prisma.job.create({
data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } },
});
await ack(user, owner, repoName, prNumber, "🚀 Starting preview...");
} else {
await ack(user, owner, repoName, prNumber, `️ Preview is already \`${preview.status.toLowerCase()}\`.`);
}
} else if (command === "logs") {
if (!preview) return;
if (!preview) {
await ack(user, owner, repoName, prNumber, "️ No preview exists for this PR yet.");
return;
}
const lastLines = (preview.logs || "").split("\n").slice(-50).join("\n");
const logBody = `**PP Logs** (last 50 lines)\n\`\`\`\n${lastLines}\n\`\`\``;
await postComment(user, owner, repoName, prNumber, logBody);
@@ -259,9 +298,48 @@ async function handleIssueCommentEvent(user: any, payload: any) {
} else {
await prisma.preview.update({ where: { id: preview.id }, data: { status: "IGNORED" } });
}
await ack(user, owner, repoName, prNumber, "🔕 This PR is now ignored. Future pushes and commands (except `/pp start`) will be skipped.");
} else {
await ack(user, owner, repoName, prNumber, `❓ Unknown command \`/pp ${command}\`. Try \`/pp help\`.`);
}
}
// Posts a short acknowledgement comment for a command. Best-effort: a Gitea
// failure here must never bubble up and break command processing.
async function ack(user: any, owner: string, repo: string, prNumber: number, message: string): Promise<void> {
try {
await postComment(user, owner, repo, prNumber, message);
} catch (e) {
log.warn({ e, owner, repo, prNumber }, "Failed to post command acknowledgement comment");
}
}
// The canonical `/pp` command list, also used to render `/pp help`.
export const PP_COMMANDS: { name: string; usage: string; description: string }[] = [
{ name: "rebuild", usage: "/pp rebuild", description: "Rebuild the preview — reuses the running instance, or re-provisions if stopped." },
{ name: "stop", usage: "/pp stop", description: "Stop and terminate the preview instance." },
{ name: "start", usage: "/pp start", description: "Start a stopped/ignored preview, or create one if none exists." },
{ name: "logs", usage: "/pp logs", description: "Post the last 50 lines of build/run logs as a comment." },
{ name: "ignore", usage: "/pp ignore", description: "Ignore this PR — skip future pushes and commands (except /pp start)." },
{ name: "help", usage: "/pp help", description: "Show this command reference." },
];
function buildHelpBody(disabled: string[]): string {
const rows = PP_COMMANDS.map(c => {
const off = c.name !== "help" && disabled.includes(c.name);
const cmd = off ? `~~\`${c.usage}\`~~` : `\`${c.usage}\``;
const desc = off ? `_(disabled for this repo)_ ${c.description}` : c.description;
return `| ${cmd} | ${desc} |`;
}).join("\n");
return `## 🤖 PR Previews — Commands
Only the PR author or a repo owner/admin can run these.
| Command | Description |
| --- | --- |
${rows}`;
}
async function isRepoAdmin(user: any, owner: string, repo: string, username: string): Promise<boolean> {
try {
const { getRepoCollaboratorPermission } = await import("../services/gitea");
+133
View File
@@ -0,0 +1,133 @@
import { prisma } from "../lib/db";
import { decrypt } from "../lib/encryption";
import { connectSsh, type SshSession } from "./ssh";
import { createLogger } from "../lib/logger";
const log = createLogger("APPLOGS");
// The live app-log stream is deliberately separate from a preview's persisted
// `logs` (which hold the PP deploy engine's own output). The running app's
// stdout/stderr lives on the EC2 instance — in /opt/app/pp.log for a plain
// process run, or in the container logs for docker-compose mode — so we tail it
// on demand over an SSH channel while clients are watching. Nothing here is
// written to the database; it's a passthrough to the WebSocket.
//
// A single tail per preview is shared (multiplexed) across all connected
// viewers: the first subscriber opens the SSH connection, later ones attach to
// it, and the connection is torn down once the last viewer disconnects. Recent
// output is kept in a ring buffer so a late joiner sees history immediately
// instead of a blank pane until the next line arrives.
function tailCommand(useDockerCompose: boolean, composeFilePath: string | null): string {
if (useDockerCompose) {
const composePath = composeFilePath || "docker-compose.yml";
// `--tail=200` seeds recent history so the viewer isn't blank on connect;
// `-f` follows new output. Compose writes container logs to stdout/stderr,
// both of which execStream captures.
return `cd /opt/app && sudo docker compose -f '${composePath.replace(/'/g, `'"'"'`)}' logs -f --tail=200`;
}
// `-F` (not `-f`) follows by name and retries if the file is missing or gets
// rotated — the app may not have written pp.log yet when a client connects.
return `tail -n 200 -F /opt/app/pp.log 2>&1`;
}
const RING_BUFFER_BYTES = 64 * 1024;
interface StreamEntry {
subscribers: Set<(chunk: string) => void>;
buffer: string[]; // recent chunks, replayed to late joiners
bufferBytes: number;
connecting: Promise<void>;
session: SshSession | null;
streamHandle: { close(): void } | null;
torndown: boolean;
}
const streams = new Map<number, StreamEntry>();
function pushToBuffer(entry: StreamEntry, chunk: string) {
entry.buffer.push(chunk);
entry.bufferBytes += Buffer.byteLength(chunk, "utf8");
while (entry.bufferBytes > RING_BUFFER_BYTES && entry.buffer.length > 1) {
const dropped = entry.buffer.shift()!;
entry.bufferBytes -= Buffer.byteLength(dropped, "utf8");
}
}
function broadcast(entry: StreamEntry, chunk: string) {
pushToBuffer(entry, chunk);
for (const cb of entry.subscribers) {
try { cb(chunk); } catch {}
}
}
async function openStream(previewId: number, entry: StreamEntry) {
const preview = await prisma.preview.findUnique({
where: { id: previewId },
include: { repoConfig: true },
});
if (!preview || !preview.instanceIp || !preview.sshPrivateKey) {
broadcast(entry, "[app-logs] No running instance for this preview — app logs are only available while it is live.\n");
return;
}
const privateKey = decrypt(preview.sshPrivateKey);
const cmd = tailCommand(preview.repoConfig.useDockerCompose, preview.repoConfig.composeFilePath);
try {
const session = await connectSsh(preview.instanceIp, privateKey, 30_000);
// The last viewer may have left while we were connecting.
if (entry.torndown || entry.subscribers.size === 0) {
session.close();
return;
}
entry.session = session;
entry.streamHandle = session.execStream(cmd, (chunk) => broadcast(entry, chunk));
} catch (e: any) {
broadcast(entry, `[app-logs] Failed to connect to instance: ${e.message}\n`);
log.warn({ previewId, error: e.message }, "App log stream failed to connect");
}
}
function teardown(previewId: number, entry: StreamEntry) {
entry.torndown = true;
streams.delete(previewId);
try { entry.streamHandle?.close(); } catch {}
try { entry.session?.close(); } catch {}
}
// Subscribe to a preview's live app logs. The returned function unsubscribes;
// when the last subscriber leaves, the shared SSH tail is torn down.
export async function streamAppLogs(
previewId: number,
onData: (chunk: string) => void,
): Promise<() => void> {
let entry = streams.get(previewId);
if (!entry) {
entry = {
subscribers: new Set(),
buffer: [],
bufferBytes: 0,
connecting: Promise.resolve(),
session: null,
streamHandle: null,
torndown: false,
};
streams.set(previewId, entry);
entry.connecting = openStream(previewId, entry);
}
entry.subscribers.add(onData);
// Replay buffered history so a late joiner isn't staring at a blank pane.
for (const chunk of entry.buffer) {
try { onData(chunk); } catch {}
}
const current = entry;
return () => {
current.subscribers.delete(onData);
if (current.subscribers.size === 0) teardown(previewId, current);
};
}
+340 -49
View File
@@ -10,10 +10,13 @@ import {
launchInstance,
waitForInstanceRunning,
terminateInstance,
waitForInstanceTerminated,
deleteKeyPairAws,
deleteSecurityGroupAws,
} from "./ec2";
import { connectSsh, type SshSession } from "./ssh";
import { connectSsh, waitForBootstrap, type SshSession } from "./ssh";
import { computePreviewCostUsd, sessionCostUsd } from "../lib/cost";
import { normalizeAptPackages, normalizePreinstallTools } from "../lib/preinstall";
import {
buildPrCommentBody,
postComment,
@@ -23,6 +26,15 @@ import type { Preview, RepoConfig, User } from "@prisma/client";
const log = createLogger("DEPLOY");
// Block until Ubuntu's apt-daily / unattended-upgrades timers release every apt
// lock, so a following `apt-get update`/`install` doesn't die with "Could not
// get lock". Polls all four lock files for up to 300s (150 * 2s). `fuser`
// returns 0 while a file is in use, non-zero once it's free.
const APT_WAIT =
`sudo bash -c 'for i in $(seq 1 150); do ` +
`fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1 ` +
`&& sleep 2 || break; done'`;
const activeSshSessions = new Map<number, SshSession>();
export function abortJobForPreview(previewId: number) {
@@ -33,12 +45,23 @@ export function abortJobForPreview(previewId: number) {
}
}
// Prefix every non-empty line of a log chunk with a local-time `[HH:MM:SS]`
// stamp. SSH execs buffer their whole output and hand it to appendLog at once,
// so a command's lines share the stamp of the moment it completed — the useful
// signal is *when each step happened*, which this captures. Blank lines (e.g.
// the `\n---\n` separators) are left untouched so the log keeps its spacing.
function stampLines(text: string): string {
const stamp = `[${new Date().toTimeString().slice(0, 8)}] `;
return text.replace(/^(?=.)/gm, stamp);
}
export async function appendLog(previewId: number, text: string) {
const settings = await getAdminSettings();
const preview = await prisma.preview.findUnique({ where: { id: previewId } });
if (!preview) return;
let logs = (preview.logs || "") + text;
const stamped = stampLines(text);
let logs = (preview.logs || "") + stamped;
if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const marker = "--- logs truncated ---\n";
while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
@@ -51,7 +74,7 @@ export async function appendLog(previewId: number, text: string) {
await prisma.preview.update({ where: { id: previewId }, data: { logs } });
broadcastLogUpdate(previewId, text);
broadcastLogUpdate(previewId, stamped);
}
const logSubscribers = new Map<number, Set<(text: string) => void>>();
@@ -94,7 +117,14 @@ export async function runDeploy(jobId: number) {
if (isFirstDeploy) {
await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);
} else {
await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle);
const freshPreview = await prisma.preview.findUnique({ where: { id: preview.id } });
if (freshPreview?.instanceType && freshPreview.instanceType !== repoConfig.instanceType) {
await appendLog(preview.id, `[PP] Instance type changed from ${freshPreview.instanceType} to ${repoConfig.instanceType}; provisioning a fresh instance.\n`);
await stopPreview(preview.id, "STOPPED", "Instance type changed", true);
await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);
} else {
await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle);
}
}
await prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: new Date() } });
@@ -168,7 +198,11 @@ async function firstDeploy(
throw new Error("Concurrent instance limit reached");
}
await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle });
// A restart from a STOPPED/IGNORED state reuses the same Preview row (see
// `/pp start` and stopped-branch push in webhook.ts), so its `logs` still hold
// the previous run's output. Clear them here so a fresh provision starts with a
// clean log instead of appending under the old EC2's build output.
await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle, logs: "", stopReason: null, stoppedAt: null });
const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
@@ -207,7 +241,27 @@ async function firstDeploy(
},
});
await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
// Start a cost session for the new instance. If a prior session was left open
// (e.g. a failed deploy whose instance was never stopped before this restart),
// finalize its accrued cost first so it isn't lost when we overwrite the launch
// timestamp. See lib/cost.ts.
const priorCost = await prisma.preview.findUnique({
where: { id: previewId },
select: { accumulatedCostUsd: true, instanceLaunchedAt: true, instanceType: true },
});
let accumulatedCostUsd = priorCost?.accumulatedCostUsd ?? 0;
if (priorCost?.instanceLaunchedAt) {
accumulatedCostUsd += sessionCostUsd(priorCost.instanceLaunchedAt, new Date(), priorCost.instanceType);
}
await prisma.preview.update({
where: { id: previewId },
data: {
instanceId,
instanceType: repoConfig.instanceType,
instanceLaunchedAt: new Date(),
accumulatedCostUsd,
},
});
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for running state...\n`);
checkAbort(previewId);
@@ -223,25 +277,33 @@ async function firstDeploy(
})
);
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH (as root)...\n`);
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH (as ubuntu)...\n`);
checkAbort(previewId);
// Ubuntu cloud images support command execution through their provisioned user.
const sshSession = await connectSsh(instanceIp, privateKey, 300_000);
// The `ubuntu` user accepts SSH almost immediately at boot, but cloud-init is
// still disabling apt timers and installing PP's base tools. Open a session
// just to wait for its completion marker before touching the instance,
// otherwise we can still hit apt-lock races below.
const bootstrapSession = await connectSsh(instanceIp, privateKey, 300_000);
activeSshSessions.set(previewId, bootstrapSession);
await appendLog(previewId, `[PP] Waiting for instance bootstrap to finish...\n`);
try {
checkAbort(previewId);
await waitForBootstrap(bootstrapSession);
} finally {
bootstrapSession.close();
}
checkAbort(previewId);
// Reconnect for the actual work so the marker-wait session stays short-lived
// and cannot carry stale state into the deploy steps.
const sshSession = await connectSsh(instanceIp, privateKey, 60_000);
activeSshSessions.set(previewId, sshSession);
try {
// Root SSH is enabled as the final bootstrap step, after the completion
// marker is written. A successful root connection is therefore the
// readiness signal; polling the same long-lived SSH connection can leave
// a completed instance stuck when its exec channel goes stale.
await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`);
if (repoConfig.aptPackages.length > 0) {
await runSshStep(previewId, sshSession, `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
}
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
const parsedUrl = new URL(cloneUrl.startsWith("http") ? cloneUrl : `https://${cloneUrl}`);
parsedUrl.username = "";
@@ -249,6 +311,12 @@ async function firstDeploy(
const cleanCloneUrl = parsedUrl.toString();
const authHeader = Buffer.from(`${user.giteaUsername || ""}:${giteaPat}`).toString("base64");
// We SSH in as the unprivileged `ubuntu` user, but /opt is root-owned, so
// an unprivileged clone into /opt/app fails with EACCES. Create the target
// directory up front and hand it to ubuntu so the clone (and every later
// step) can write without sudo.
await runSshStep(previewId, sshSession, `sudo mkdir -p /opt/app && sudo chown ubuntu:ubuntu /opt/app`);
// Keep the PAT out of both preview logs and the URL. Gitea deployments can
// reject userinfo URLs, while Git's per-command HTTP header works for both
// private repositories and reverse proxies.
@@ -265,11 +333,21 @@ async function firstDeploy(
}
if (cloneResult.code !== 0) throw new Error(`git clone failed with exit code ${cloneResult.code}`);
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
// The PR ref lives in the same private repo, so the fetch needs the same
// auth as the clone — otherwise git prompts for a username and aborts.
const authOption = giteaAuthOption(authHeader, giteaPat);
await runGitStep(
previewId,
sshSession,
`cd /opt/app && git ${authOption}fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`,
`cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`,
authHeader,
giteaPat,
);
await setupAndBuild(previewId, sshSession, repoConfig, commitSha, true);
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() });
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date(), stopReason: null, stoppedAt: null });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
buildPrCommentBody({
@@ -319,17 +397,45 @@ async function redeploy(
try {
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);
} else if (freshPreview.pid) {
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);
await runSshStep(previewId, sshSession, `cd /opt/app && sudo docker compose -f ${shellQuote(composePath)} down 2>&1 || true`);
} else {
// Non-compose: stop the previous process. `pnpm start` (and npm/yarn) run
// the real server as a CHILD of the recorded PID, so killing just that PID
// can orphan the server on the app port; and a prior failed/timed-out
// deploy may not have recorded a PID at all. In both cases the next start
// dies with EADDRINUSE. Kill the recorded PID if we have one, then free the
// port itself as the authoritative backstop — `fuser -k` kills whatever is
// actually listening, clearing orphans regardless of how they were spawned.
const killPid = freshPreview.pid
? `kill ${freshPreview.pid} 2>/dev/null || true; sleep 3; kill -9 ${freshPreview.pid} 2>/dev/null || true; `
: "";
await runSshStep(previewId, sshSession, `bash -c '${killPid}fuser -k ${repoConfig.port}/tcp 2>/dev/null || true; sleep 1; true'`);
}
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);
// Reuse the same per-command auth as firstDeploy; the checked-out repo has
// no persisted credentials, so every fetch must supply the header itself.
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
const authHeader = Buffer.from(`${user.giteaUsername || ""}:${giteaPat}`).toString("base64");
const authOption = giteaAuthOption(authHeader, giteaPat);
// The working tree is already on the `pp-pr` branch from the first deploy,
// and git refuses to fetch directly into a checked-out branch ref of a
// non-bare repo ("Refusing to fetch into current branch"). Fetch into
// FETCH_HEAD instead (no branch-ref update), then force `pp-pr` to it with
// `checkout -B`, which also updates the working tree — equivalent to the old
// fetch+checkout+reset but without touching the live branch ref.
await runGitStep(
previewId,
sshSession,
`cd /opt/app && git ${authOption}fetch origin pull/${prNumber}/head && git checkout -B pp-pr FETCH_HEAD`,
`cd /opt/app && git fetch origin pull/${prNumber}/head && git checkout -B pp-pr FETCH_HEAD`,
authHeader,
giteaPat,
);
await updateStatus(previewId, "BUILDING");
await setupAndBuild(previewId, sshSession, repoConfig, commitSha, false);
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() });
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date(), stopReason: null, stoppedAt: null });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, newCommentId,
buildPrCommentBody({
@@ -346,8 +452,21 @@ async function redeploy(
const NVM_PREFIX = `export NVM_DIR="$HOME/.nvm"; source "$NVM_DIR/nvm.sh" 2>/dev/null;`;
function withNvm(cmd: string): string {
return `bash -c '${NVM_PREFIX} ${cmd.replace(/'/g, `'"'"'`)}'`;
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function bashLc(cmd: string): string {
return `bash -lc ${shellQuote(cmd)}`;
}
function usesNode(repoConfig: RepoConfig): boolean {
return normalizePreinstallTools((repoConfig as any).preinstallTools, repoConfig.useDockerCompose).includes("node");
}
function withRuntime(repoConfig: RepoConfig, cmd: string): string {
const prefix = usesNode(repoConfig) ? `${NVM_PREFIX} ` : "";
return bashLc(`${prefix}cd /opt/app && ${cmd}`);
}
async function setupAndBuild(
@@ -357,9 +476,7 @@ async function setupAndBuild(
commitSha: string,
isFirstProvision: boolean,
) {
// Detect and use Node version
const nvmCmd = `${NVM_PREFIX} if [ -f /opt/app/.nvmrc ]; then nvm install && nvm use; else nvm use default; fi`;
await runSshStep(previewId, sshSession, `bash -c '${nvmCmd}'`, false);
await ensurePreinstall(previewId, sshSession, repoConfig);
// Write .env file safely using base64 to handle special chars and newlines
const envVars = repoConfig.envVars as Record<string, string>;
@@ -371,7 +488,7 @@ async function setupAndBuild(
if (isFirstProvision) {
for (const cmd of repoConfig.setupCommands) {
await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`));
await runSshStep(previewId, sshSession, withRuntime(repoConfig, cmd));
}
}
@@ -379,26 +496,146 @@ async function setupAndBuild(
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`);
await runSshStep(previewId, sshSession, `cd /opt/app && sudo docker compose -f ${shellQuote(composePath)} up -d --build --force-recreate 2>&1`);
} else {
// We SSH in and build as `ubuntu`, but a prior Docker-mode run (or a compose
// bind mount) can leave root-owned files under /opt/app — chiefly
// dependency folders — which makes package managers fail with EACCES when
// they try to rewrite them. Reclaim ownership before the build so switching
// a preview from docker-compose to a plain process doesn't wedge on
// permissions.
await runSshStep(previewId, sshSession, `sudo chown -R ubuntu:ubuntu /opt/app`);
for (const cmd of repoConfig.buildCommands) {
await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`));
await runSshStep(previewId, sshSession, withRuntime(repoConfig, cmd));
}
for (const cmd of repoConfig.postBuildCommands) {
await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`));
await runSshStep(previewId, sshSession, withRuntime(repoConfig, cmd));
}
if (repoConfig.runCommand) {
const startCmd = withNvm(`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`);
const res = await runSshStep(previewId, sshSession, startCmd);
const pid = parseInt(res.stdout.trim(), 10);
if (!isNaN(pid)) {
await prisma.preview.update({ where: { id: previewId }, data: { pid } });
}
// Non-compose mode has no start step other than `runCommand`. If it's blank
// there is nothing to launch — fail loudly instead of marking the preview
// RUNNING with no process behind it (a misleading green preview).
const runCommand = (repoConfig.runCommand || "").trim();
if (!runCommand) {
throw new Error(
"No run command is configured for this repo (non-compose mode), so there is nothing to start. " +
'Set a Run Command in the repo config (e.g. "pnpm start"), or enable Docker Compose mode.',
);
}
// Detaching a long-lived server over SSH is subtle. A plain `nohup <cmd> &`
// leaves the process in the exec channel's process group, and sshd keeps the
// channel open until it exits — so the exec never returns and PP's 30s cap
// fires even though the app started fine. (Verified on a live instance: a
// bare `sleep` detaches instantly, but any long-running node/pnpm process
// hangs the channel.) `disown` removes the job from the shell so the channel
// can close immediately; `nohup` keeps it running past our session teardown;
// `< /dev/null` frees stdin. `echo $!` still yields the launched PID.
const startCmd = withRuntime(repoConfig, `{ nohup ${runCommand} > /opt/app/pp.log 2>&1 < /dev/null & } ; disown ; echo $!`);
const res = await runSshStep(previewId, sshSession, startCmd);
const pid = parseInt(res.stdout.trim(), 10);
if (!isNaN(pid)) {
await prisma.preview.update({ where: { id: previewId }, data: { pid } });
await healthCheck(previewId, sshSession, pid, repoConfig.port);
}
}
}
async function ensurePreinstall(previewId: number, sshSession: SshSession, repoConfig: RepoConfig) {
const tools = normalizePreinstallTools((repoConfig as any).preinstallTools, repoConfig.useDockerCompose);
if (tools.length === 0 && repoConfig.aptPackages.length === 0) {
await appendLog(previewId, "[PP] No preinstall options selected.\n");
return;
}
await appendLog(previewId, `[PP] Installing selected preinstall options: ${tools.length ? tools.join(", ") : "custom apt packages only"}.\n`);
const apt = `sudo DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300`;
const aptPackages = new Set<string>();
if (tools.includes("python")) ["python3", "python3-pip", "python3-venv"].forEach(p => aptPackages.add(p));
if (tools.includes("go")) aptPackages.add("golang-go");
if (tools.includes("lua")) ["lua5.4", "luarocks"].forEach(p => aptPackages.add(p));
if (tools.includes("build-essential")) ["build-essential", "pkg-config"].forEach(p => aptPackages.add(p));
for (const pkg of normalizeAptPackages(repoConfig.aptPackages)) aptPackages.add(pkg);
if (aptPackages.size > 0) {
await runSshStep(previewId, sshSession, `${APT_WAIT}; ${apt} update && ${apt} install -y ${[...aptPackages].join(" ")}`);
}
if (tools.includes("docker")) {
await runSshStep(
previewId,
sshSession,
`${APT_WAIT}; if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sudo sh; fi; ` +
`sudo systemctl enable docker && sudo systemctl start docker; ${APT_WAIT}; ${apt} update && ${apt} install -y docker-compose-plugin; sudo usermod -aG docker ubuntu`,
);
}
if (tools.includes("node")) {
const nodeVersion = String((repoConfig as any).nodeVersion || "lts/*");
await runSshStep(
previewId,
sshSession,
bashLc(
`if [ ! -s "$HOME/.nvm/nvm.sh" ]; then curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash; fi; ` +
`export NVM_DIR="$HOME/.nvm"; source "$NVM_DIR/nvm.sh"; ` +
`if [ -f /opt/app/.nvmrc ]; then cd /opt/app && nvm install && nvm use; ` +
`else nvm install ${shellQuote(nodeVersion)} && nvm alias default ${shellQuote(nodeVersion)} && nvm use default; fi`,
),
);
}
}
// Wait for the freshly-launched app to come up. `nohup ... &` detaches the
// process and routes its output to pp.log, so nothing from the server reaches
// the preview logs — a clean start and an instant crash look identical. Poll
// until either the process dies or *anything* accepts a TCP connection on the
// app port (a bare connect, not an HTTP 2xx — the app owns the port either way),
// then surface the first lines of pp.log so the startup is visible. Polls from
// Node in short bursts because a single SSH exec is capped at 30s (see ssh.ts),
// so a long in-shell loop would trip that timeout.
const HEALTH_TIMEOUT_MS = 60_000;
const HEALTH_INTERVAL_MS = 2_000;
async function healthCheck(previewId: number, sshSession: SshSession, pid: number, port: number) {
await appendLog(previewId, `[PP] Waiting up to ${HEALTH_TIMEOUT_MS / 1000}s for the app to respond on port ${port}...\n`);
const start = Date.now();
let healthy = false;
let exited = false;
while (Date.now() - start < HEALTH_TIMEOUT_MS) {
checkAbortSession(sshSession);
// exit 2 = process gone, 0 = port accepted a connection, 1 = not yet.
// `/dev/tcp` is a bash builtin, so no nc/curl dependency; the connect runs
// in a subshell so its fd can't leak into later commands on this channel.
const probe = await sshSession.exec(
`bash -c 'kill -0 ${pid} 2>/dev/null || exit 2; (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null && exit 0 || exit 1'`,
);
if (probe.code === 0) { healthy = true; break; }
if (probe.code === 2) { exited = true; break; }
await sleep(HEALTH_INTERVAL_MS);
}
const tail = await sshSession.exec(`tail -n 40 /opt/app/pp.log 2>/dev/null`);
await appendLog(previewId, `--- server startup output (pp.log) ---\n`);
if (tail.stdout) await appendLog(previewId, tail.stdout);
await appendLog(previewId, `\n---\n`);
if (healthy) {
await appendLog(previewId, `[PP] Health check passed — port ${port} is accepting connections (pid ${pid}).\n`);
} else if (exited) {
throw new Error(`Server process ${pid} exited during startup — see pp.log output above.`);
} else {
throw new Error(`Health check timed out — nothing responded on port ${port} within ${HEALTH_TIMEOUT_MS / 1000}s. See pp.log output above.`);
}
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) {
checkAbortSession(sshSession);
await appendLog(previewId, `$ ${command}\n`);
@@ -411,6 +648,37 @@ async function runSshStep(previewId: number, sshSession: SshSession, command: st
return res;
}
// Like runSshStep, but for git commands that must carry the Gitea auth header.
// The real command embeds the base64 credential inline (per-command, never
// persisted to .git/config); `displayCommand` is the credential-free form we
// echo to the logs, and any secret leaking into stdout/stderr is masked. This
// mirrors the masking the initial clone already does.
async function runGitStep(
previewId: number,
sshSession: SshSession,
realCommand: string,
displayCommand: string,
...maskValues: string[]
) {
checkAbortSession(sshSession);
await appendLog(previewId, `$ ${displayCommand}\n`);
const res = await sshSession.exec(realCommand);
const mask = (s: string) => maskValues.reduce((acc, m) => (m ? acc.split(m).join("****") : acc), s);
if (res.stdout) await appendLog(previewId, mask(res.stdout));
if (res.stderr) await appendLog(previewId, mask(res.stderr));
if (res.code !== 0) {
throw new Error(`Command failed with exit code ${res.code}: ${displayCommand}`);
}
return res;
}
// Builds the `-c http.extraHeader=...` git option that authenticates a single
// git invocation against a private Gitea repo. Empty when there is no PAT
// (public repos clone/fetch anonymously).
function giteaAuthOption(authHeader: string, giteaPat: string): string {
return giteaPat ? `-c http.extraHeader='Authorization: Basic ${authHeader}' ` : "";
}
function checkAbortSession(session: SshSession) {
if (session.aborted) throw new Error("ABORTED");
}
@@ -429,7 +697,7 @@ function checkAbort(previewId: number) {
}
}
export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED" = "STOPPED") {
export async function stopPreview(previewId: number, status: "STOPPED" | "FAILED" = "STOPPED", reason = "Stopped", waitForCleanup = false) {
const preview = await prisma.preview.findUnique({
where: { id: previewId },
include: { repoConfig: { include: { user: true } } },
@@ -441,34 +709,57 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED
if (preview.instanceId) {
const ec2 = makeEc2Client(user);
const instanceId = preview.instanceId;
// Terminate instance first, then clean up key pair and security group
try {
await terminateInstance(ec2, preview.instanceId);
await terminateInstance(ec2, instanceId);
} catch (e) {
log.warn({ e, instanceId: preview.instanceId }, "Failed to terminate instance");
log.warn({ e, instanceId }, "Failed to terminate instance");
}
// Delete key pair immediately (doesn't depend on instance state)
try {
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
} catch {}
// Delete security group after a delay to allow instance ENI detachment
setTimeout(async () => {
// The security group can't be deleted until the instance's ENI is released,
// which only happens once the instance is fully terminated. Wait for that,
// then delete (deleteSecurityGroupAws also retries on DependencyViolation).
const cleanupSecurityGroup = async () => {
try {
await waitForInstanceTerminated(ec2, instanceId);
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
} catch (e) {
log.warn({ e, previewId }, "Failed to delete security group after termination");
}
}, 30_000);
};
if (waitForCleanup) await cleanupSecurityGroup();
else void cleanupSecurityGroup();
}
// Close out the live cost session: fold the running instance's accrued cost
// into the finalized total and clear the launch timestamp so it stops billing.
const stoppedAt = new Date();
const finalCostUsd = computePreviewCostUsd(
{
accumulatedCostUsd: preview.accumulatedCostUsd,
instanceLaunchedAt: preview.instanceLaunchedAt,
instanceType: preview.instanceType,
},
stoppedAt,
);
const stopReason = reason.trim().slice(0, 128) || "Stopped";
await prisma.preview.update({
where: { id: previewId },
data: {
status: reason,
stoppedAt: new Date(),
status,
stopReason,
stoppedAt,
sshPrivateKey: null,
sshKeyName: null,
instanceId: null,
instanceLaunchedAt: null,
accumulatedCostUsd: finalCostUsd,
},
});
@@ -476,7 +767,7 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: "⚫ Stopped (inactivity timeout / PR closed / manual stop)",
status: `⚫ Stopped (${stopReason})`,
commitSha: preview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
+69 -16
View File
@@ -59,6 +59,15 @@ export function makeStsClient(user: User): STSClient {
export async function validateAwsCredentials(user: User): Promise<{ success: boolean; arn?: string; error?: string }> {
try {
const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : "";
const region = user.awsRegion ?? "";
log.info("validateAwsCredentials: signer inputs", {
accessKeyId: JSON.stringify(accessKeyId),
accessKeyIdLen: accessKeyId.length,
accessKeyIdCharCodes: [...accessKeyId].map((c) => c.charCodeAt(0)),
region: JSON.stringify(region),
regionCharCodes: [...region].map((c) => c.charCodeAt(0)),
});
const sts = makeStsClient(user);
const res = await sts.send(new GetCallerIdentityCommand({}));
return { success: true, arn: res.Arn };
@@ -118,20 +127,24 @@ const BOOTSTRAP_SCRIPT = `#!/bin/bash
set -euo pipefail
exec > >(tee -a /var/log/pp-bootstrap.log) 2>&1
trap 'touch /var/lib/pp-bootstrap-failed' ERR
# Ubuntu's apt-daily / unattended-upgrades timers fire on boot and hold the apt
# locks, which makes apt-get die immediately ("Could not get lock") — not just
# for our bootstrap, but for any user setup command that shells out to apt (e.g.
# the get.docker.com install script). "mask --now" stops the units if they are
# already running AND blocks them from being re-triggered for the life of this
# ephemeral instance. Then wait out anything mid-flight before each apt-get.
systemctl mask --now apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service unattended-upgrades.service 2>/dev/null || true
apt_wait() {
for i in $(seq 1 150); do
fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1 && sleep 2 || break
done
}
apt_wait
apt-get update -y
apt-get install -y curl git unzip build-essential
# Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
apt-get install -y docker-compose-plugin
# PP deploys as the image's supported SSH user. Give it Docker access and its
# own Node runtime rather than attempting to override Ubuntu's root SSH policy.
usermod -aG docker ubuntu
sudo -u ubuntu -H bash -lc 'curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash'
sudo -u ubuntu -H bash -lc 'export NVM_DIR="$HOME/.nvm"; source "$NVM_DIR/nvm.sh"; nvm install --lts; nvm alias default lts/*'
apt_wait
apt-get install -y ca-certificates curl git unzip
touch /var/lib/pp-bootstrap-done
`;
@@ -188,6 +201,31 @@ export async function terminateInstance(ec2: EC2Client, instanceId: string): Pro
await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));
}
// Waits for an instance to reach the `terminated` state so its ENI is released
// and the security group no longer has a dependent object. Resolves (rather than
// throwing) on timeout so callers can still attempt SG deletion with retries.
export async function waitForInstanceTerminated(
ec2: EC2Client,
instanceId: string,
timeoutMs = 180_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await ec2.send(new DescribeInstancesCommand({
InstanceIds: [instanceId],
}));
const state = res.Reservations?.[0]?.Instances?.[0]?.State?.Name;
if (!state || state === "terminated") return;
} catch (e: any) {
// Instance record aged out / never existed — nothing left to wait on.
if (e.name === "InvalidInstanceID.NotFound") return;
throw e;
}
await sleep(5000);
}
}
export async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise<void> {
try {
await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName }));
@@ -202,9 +240,24 @@ export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string):
Filters: [{ Name: "group-name", Values: [groupName] }],
}));
const groupId = describe.SecurityGroups?.[0]?.GroupId;
if (groupId) {
await sleep(5000);
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
if (!groupId) return;
// The terminated instance's ENI can linger for a while after the instance
// itself is gone; DeleteSecurityGroup fails with DependencyViolation until
// that ENI is released. Retry with backoff instead of failing outright.
const maxAttempts = 12;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
return;
} catch (e: any) {
if (e.name === "InvalidGroup.NotFound") return;
if (e.name === "DependencyViolation" && attempt < maxAttempts) {
await sleep(10_000);
continue;
}
throw e;
}
}
} catch (e) {
log.warn({ e, groupName }, "Failed to delete security group");
+136 -7
View File
@@ -23,13 +23,61 @@ export async function validateGiteaUrl(url: string): Promise<{ success: boolean;
}
}
// Authenticates the PAT and confirms it belongs to the expected account. Gitea 1.19+
// scopes tokens and does not expose granted scopes via the API, so we cannot pre-check
// write:issue non-destructively — but authenticating here catches invalid/expired/wrong
// tokens at setup instead of failing at deploy time.
export async function validateGiteaToken(
url: string,
pat: string,
expectedUsername: string,
): Promise<{ success: boolean; error?: string; login?: string }> {
try {
const res = await axios.get(`${url}/api/v1/user`, {
headers: { Authorization: `token ${pat}` },
timeout: 10000,
});
const login: string | undefined = res.data?.login;
if (expectedUsername && login && login.toLowerCase() !== expectedUsername.toLowerCase()) {
return {
success: false,
login,
error: `Token belongs to "${login}", not "${expectedUsername}". Use a token created by ${expectedUsername}.`,
};
}
return { success: true, login };
} catch (e: any) {
const status = e?.response?.status;
if (status === 401) return { success: false, error: "Token is invalid or expired." };
return { success: false, error: e?.message ?? "Token validation failed" };
}
}
// Translates opaque Gitea API errors into actionable messages. A 403 on a write almost
// always means the PAT lacks the write scope for that resource.
function giteaWriteError(e: any, action: string): Error {
if (e?.response?.status === 403) {
return new Error(
`Gitea denied ${action} (403 Forbidden). The Personal Access Token is missing write access. ` +
`Recreate it with Read and Write permission for the "issue" and "repository" scopes.`,
);
}
return e instanceof Error ? e : new Error(String(e));
}
// Lists only the repos the authenticated user owns or has access to (owned +
// collaborator + org member). We deliberately use /user/repos rather than
// /repos/search: for a Gitea site admin, /repos/search returns EVERY repo on the
// instance, which would let admins see and manage repos they have no stake in.
// /user/repos is user-scoped regardless of admin status. Note the shape differs:
// /user/repos returns a bare array, not the /repos/search { ok, data } envelope.
export async function fetchUserRepos(user: User): Promise<any[]> {
const api = giteaApi(user);
const repos: any[] = [];
let page = 1;
while (true) {
const res = await api.get(`/repos/search?limit=50&page=${page}`);
const data = res.data?.data ?? [];
const res = await api.get(`/user/repos?limit=50&page=${page}`);
const data = Array.isArray(res.data) ? res.data : [];
if (data.length === 0) break;
repos.push(...data);
if (data.length < 50) break;
@@ -38,16 +86,34 @@ export async function fetchUserRepos(user: User): Promise<any[]> {
return repos;
}
export async function fetchRepo(user: User, owner: string, repo: string): Promise<any> {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}`);
return res.data;
}
// The canonical event set for every PP-managed webhook. Gitea splits PR events:
// "pull_request" fires on opened/closed/reopened/edited, but a push of new
// commits to the PR branch fires the SEPARATE "pull_request_sync" event.
// Without it, the synchronize webhook is never delivered and previews never
// redeploy on push. "issue_comment" carries the `/pp ...` commands.
export const PP_WEBHOOK_EVENTS = ["pull_request", "pull_request_sync", "issue_comment"];
// Human-readable label shown in Gitea's webhook list. Applied on creation and
// backfilled onto older unnamed webhooks during boot reconciliation.
export const PP_WEBHOOK_NAME = "PR Previews";
export async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise<number> {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/hooks`, {
type: "gitea",
name: PP_WEBHOOK_NAME,
config: {
url: webhookUrl,
secret,
content_type: "json",
},
events: ["pull_request", "issue_comment"],
events: PP_WEBHOOK_EVENTS,
active: true,
});
return res.data.id;
@@ -60,26 +126,89 @@ export async function deleteWebhook(user: User, owner: string, repo: string, hoo
export async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise<void> {
const api = giteaApi(user);
const hook = await getWebhook(user, owner, repo, hookId);
if (!hook) throw new Error("Webhook no longer exists in Gitea");
const currentEvents: string[] = Array.isArray(hook.events) ? hook.events : [];
await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {
name: PP_WEBHOOK_NAME,
config: {
url: webhookUrl,
secret: newSecret,
content_type: "json",
},
events: ["pull_request", "issue_comment"],
events: [...new Set([...currentEvents, ...PP_WEBHOOK_EVENTS])],
active: true,
});
}
// Fetches a single webhook. Returns null on 404 (webhook deleted in Gitea while
// PP still has its id on the RepoConfig).
export async function getWebhook(user: User, owner: string, repo: string, hookId: string): Promise<any | null> {
const api = giteaApi(user);
try {
const res = await api.get(`/repos/${owner}/${repo}/hooks/${hookId}`);
return res.data;
} catch (e: any) {
if (e?.response?.status === 404) return null;
throw e;
}
}
export type WebhookReconcileResult =
| { status: "missing" }
| { status: "ok" }
| { status: "patched"; addedEvents: string[]; named: boolean; reactivated: boolean };
// Brings an already-registered webhook up to the current PP standard without
// tearing it down: unions in any missing events (chiefly "pull_request_sync"
// for webhooks created before that fix), names it if it was left unnamed, and
// re-activates it if disabled. User-added extra events are preserved. Patches
// only when something actually differs, so re-runs are no-ops.
export async function reconcileWebhook(user: User, owner: string, repo: string, hookId: string): Promise<WebhookReconcileResult> {
const hook = await getWebhook(user, owner, repo, hookId);
if (!hook) return { status: "missing" };
const currentEvents: string[] = Array.isArray(hook.events) ? hook.events : [];
const missingEvents = PP_WEBHOOK_EVENTS.filter(e => !currentEvents.includes(e));
const hasName = typeof hook.name === "string" && hook.name.trim().length > 0;
const inactive = hook.active === false;
if (missingEvents.length === 0 && hasName && !inactive) return { status: "ok" };
// Gitea's edit-hook API resets an omitted/empty `events` array to push-only,
// silently disabling every other trigger. So ANY patch must re-send the full
// desired event set (union of existing + PP events) and `active` — never a
// partial body that would wipe the triggers we depend on.
const patch: any = {
events: [...new Set([...currentEvents, ...PP_WEBHOOK_EVENTS])],
active: true,
};
if (!hasName) patch.name = PP_WEBHOOK_NAME;
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, patch);
return { status: "patched", addedEvents: missingEvents, named: !hasName, reactivated: inactive };
}
export async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise<number> {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });
return res.data.id;
try {
const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });
return res.data.id;
} catch (e: any) {
throw giteaWriteError(e, "posting a PR comment");
}
}
export async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise<void> {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
try {
await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
} catch (e: any) {
throw giteaWriteError(e, "updating a PR comment");
}
}
export async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise<boolean> {
+52 -4
View File
@@ -5,6 +5,11 @@ const log = createLogger("SSH");
export interface SshSession {
exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>;
// Run a long-lived command (e.g. `tail -f`) and receive its stdout/stderr as
// it arrives. Unlike exec(), this never buffers or times out — the channel
// stays open until the returned handle's close() is called (or the connection
// ends). Used to stream live app logs off a running instance.
execStream(command: string, onData: (chunk: string) => void): { close(): void };
close(): void;
aborted: boolean;
abort(): void;
@@ -28,6 +33,26 @@ export async function connectSsh(host: string, privateKey: string, maxWaitMs = 3
if (aborted) throw new Error("SSH session aborted");
return execOnConn(conn, command);
},
execStream(command: string, onData: (chunk: string) => void) {
let stream: any = null;
let closed = false;
conn.exec(command, (err, s) => {
if (err) {
if (!closed) onData(`[app-logs] stream error: ${err.message}\n`);
return;
}
if (closed) { try { s.close(); } catch {} return; }
stream = s;
s.on("data", (d: Buffer) => onData(d.toString()));
s.stderr.on("data", (d: Buffer) => onData(d.toString()));
});
return {
close() {
closed = true;
try { stream?.close(); } catch {}
},
};
},
close() {
try { conn.end(); } catch {}
},
@@ -95,14 +120,37 @@ function execOnConn(conn: Client, command: string): Promise<{ stdout: string; st
});
}
// The `ubuntu` user accepts SSH almost immediately at boot — long before the
// cloud-init UserData bootstrap has finished disabling apt-daily and installing
// the small base dependency set PP needs before SSH-driven setup begins.
// Proceeding early can still cause apt-lock failures in the deploy steps. Block until the bootstrap writes its
// done marker; bail out fast (with the tail of its log) if it writes the failure
// marker instead. Uses short one-shot execs so a stale channel just retries.
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
if (session.aborted) throw new Error("SSH session aborted");
try {
const res = await session.exec("test -f /var/lib/pp-bootstrap-done && echo done || echo waiting");
if (res.stdout.trim() === "done") return;
} catch {
// transient SSH exec error (e.g. timeout, channel reset) — retry
const res = await session.exec(
"if [ -f /var/lib/pp-bootstrap-failed ]; then echo failed; " +
"elif [ -f /var/lib/pp-bootstrap-done ]; then echo done; else echo waiting; fi"
);
const state = res.stdout.trim();
if (state === "done") return;
if (state === "failed") {
let tail = "";
try {
const logRes = await session.exec("tail -n 30 /var/log/pp-bootstrap.log 2>/dev/null || true");
tail = logRes.stdout.trim();
} catch {
// best effort — the failure marker alone is enough to abort
}
throw new Error(`EC2 bootstrap failed${tail ? `:\n${tail}` : ""}`);
}
} catch (e: any) {
// A bootstrap failure we detected above must propagate; only swallow
// transient SSH exec errors (timeout, channel reset) and retry.
if (e?.message?.startsWith("EC2 bootstrap failed")) throw e;
}
await sleep(10_000);
}
+48
View File
@@ -0,0 +1,48 @@
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { reconcileWebhook } from "./gitea";
const log = createLogger("WEBHOOK_RECONCILE");
// On boot, bring every PP-managed Gitea webhook up to the current standard so
// existing installs self-heal without the operator having to disable/re-enable
// each repo. Chiefly backfills the "pull_request_sync" event (added after these
// webhooks were first registered — without it pushes to a PR branch never
// redeploy) and names any webhook left unnamed. Errors are per-repo so one bad
// PAT or deleted repo can't abort the whole pass.
export async function runWebhookReconciliation() {
log.info("Reconciling Gitea webhooks");
const configs = await prisma.repoConfig.findMany({
where: { isEnabled: true, giteaWebhookId: { not: null } },
include: { user: true },
});
let patched = 0;
let named = 0;
let missing = 0;
for (const config of configs) {
const { user, repoOwner, repoName, giteaWebhookId } = config;
if (!user?.giteaInstanceUrl || !user?.giteaPAT || !giteaWebhookId) continue;
try {
const result = await reconcileWebhook(user as any, repoOwner, repoName, giteaWebhookId);
if (result.status === "missing") {
missing++;
log.warn({ repo: `${repoOwner}/${repoName}`, giteaWebhookId }, "Webhook no longer exists in Gitea");
} else if (result.status === "patched") {
patched++;
if (result.named) named++;
log.info(
{ repo: `${repoOwner}/${repoName}`, addedEvents: result.addedEvents, named: result.named, reactivated: result.reactivated },
"Webhook reconciled",
);
}
} catch (e) {
log.warn({ e, repo: `${repoOwner}/${repoName}`, userId: user.id }, "Failed to reconcile webhook");
}
}
log.info({ total: configs.length, patched, named, missing }, "Webhook reconciliation complete");
}
+6 -2
View File
@@ -6,8 +6,8 @@ import { getAdminSettings } from "../lib/adminSettings";
const log = createLogger("CRON");
export function startCronWorkers() {
// Inactivity check every 30 minutes
cron.schedule("*/30 * * * *", async () => {
// Inactivity check every 5 minutes
cron.schedule("*/5 * * * *", async () => {
try {
await checkInactivity();
} catch (e) {
@@ -24,6 +24,10 @@ export function startCronWorkers() {
}
});
// Run an inactivity check immediately on startup so previews that went idle
// while PP was down aren't left waiting for the next scheduled tick.
checkInactivity().catch((e) => log.error({ e }, "Startup inactivity check error"));
log.info("Cron workers started");
}
+7 -1
View File
@@ -76,8 +76,14 @@ async function processJob(job: { id: number; type: string; previewId: number | n
await runDeploy(job.id);
} else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") {
if (job.previewId) {
const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
const reason = typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: job.type === "INACTIVITY_STOP"
? "Inactivity timeout"
: "Stopped";
await prisma.job.update({ where: { id: job.id }, data: { status: "RUNNING", startedAt: new Date() } });
await stopPreview(job.previewId);
await stopPreview(job.previewId, "STOPPED", reason);
await prisma.job.update({ where: { id: job.id }, data: { status: "DONE", finishedAt: new Date() } });
}
}
+55 -2
View File
@@ -3,8 +3,61 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PR Previews</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta
name="description"
content="PR Previews is a self-hosted preview environment service for Gitea pull requests, provisioning live EC2 previews and posting status back to the PR."
/>
<meta
name="keywords"
content="PR previews, pull request previews, Gitea previews, self-hosted previews, EC2 preview environments, review apps"
/>
<meta name="author" content="PR Previews" />
<meta name="application-name" content="PR Previews" />
<meta name="generator" content="Vite" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="robots" content="index,follow" />
<meta name="format-detection" content="telephone=no" />
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)" />
<meta name="color-scheme" content="light dark" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PR Previews" />
<meta property="og:title" content="PR Previews" />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:alt" content="PR Previews app icon" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
property="og:description"
content="Self-hosted live preview environments for Gitea pull requests, backed by EC2 and updated from webhooks."
/>
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="PR Previews" />
<meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="PR Previews app icon" />
<meta
name="twitter:description"
content="Self-hosted live preview environments for Gitea pull requests, backed by EC2 and updated from webhooks."
/>
<title>PR Previews | Self-hosted Gitea preview environments</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="msapplication-TileColor" content="#0f172a" />
<meta name="msapplication-TileImage" content="/mstile-150x150.png" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<script>
(() => {
const savedTheme = localStorage.getItem("pp-theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = savedTheme === "light" || savedTheme === "dark" || savedTheme === "system" ? savedTheme : "system";
document.documentElement.classList.toggle("dark", theme === "dark" || (theme === "system" && prefersDark));
})();
</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.26.0",
"motion": "^12.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png" />
<TileColor>#0f172a</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "PR Previews",
"short_name": "PR Previews",
"description": "Self-hosted live preview environments for Gitea pull requests.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"theme_color": "#0f172a",
"background_color": "#0f172a",
"display": "standalone"
}
+21 -7
View File
@@ -3,16 +3,19 @@ import { Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import { AuthContext, useAuthProvider } from "./hooks/useAuth";
import { useTheme } from "./hooks/useTheme";
import { ThemeProvider, useTheme } from "./hooks/useTheme";
import { Layout } from "./components/Layout";
import { Login } from "./pages/Login";
import { Overview } from "./pages/Overview";
import { Dashboard } from "./pages/Dashboard";
import { PreviewDetail } from "./pages/PreviewDetail";
import { Settings } from "./pages/Settings";
import { Repos } from "./pages/Repos";
import { RepoConfig } from "./pages/RepoConfig";
import { Admin } from "./pages/Admin";
import { SetupWizard } from "./pages/SetupWizard";
import { Privacy } from "./pages/Privacy";
import { Loader2 } from "lucide-react";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = React.useContext(AuthContext);
@@ -24,7 +27,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (loading) return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-2xl animate-spin"></div>
<Loader2 size={32} className="animate-spin text-blue-600 dark:text-blue-400" />
</div>
);
@@ -48,9 +51,8 @@ function SetupCheck({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export default function App() {
const auth = useAuthProvider();
useTheme();
function AppContent({ auth }: { auth: ReturnType<typeof useAuthProvider> }) {
const { resolvedTheme } = useTheme();
return (
<AuthContext.Provider value={auth}>
@@ -60,7 +62,7 @@ export default function App() {
hideProgressBar={false}
newestOnTop
closeOnClick
theme="colored"
theme={resolvedTheme}
/>
<Routes>
<Route path="/login" element={
@@ -79,9 +81,11 @@ export default function App() {
<SetupCheck>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/" element={<Overview />} />
<Route path="/previews" element={<Dashboard />} />
<Route path="/previews/:id" element={<PreviewDetail />} />
<Route path="/repos" element={<Repos />} />
<Route path="/repos/:owner/:repo" element={<RepoConfig />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -94,3 +98,13 @@ export default function App() {
</AuthContext.Provider>
);
}
export default function App() {
const auth = useAuthProvider();
return (
<ThemeProvider>
<AppContent auth={auth} />
</ThemeProvider>
);
}
+40 -13
View File
@@ -1,13 +1,19 @@
import React from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { useTheme } from "../hooks/useTheme";
import { ThemePreference, useTheme } from "../hooks/useTheme";
import { api } from "../services/api";
import { toast } from "react-toastify";
import { Rocket, Sun, Moon, Monitor } from "lucide-react";
const themeOptions: { value: ThemePreference; label: string; Icon: typeof Sun }[] = [
{ value: "light", label: "Light", Icon: Sun },
{ value: "dark", label: "Dark", Icon: Moon },
{ value: "system", label: "System", Icon: Monitor },
];
export function Layout({ children }: { children: React.ReactNode }) {
const { user, refresh } = useAuth();
const { dark, toggle } = useTheme();
const { theme, setTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
@@ -35,14 +41,15 @@ export function Layout({ children }: { children: React.ReactNode }) {
<header className="border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80">
🚀 PR Previews
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80 inline-flex items-center gap-1.5">
<Rocket size={20} /> PR Previews
</Link>
</div>
{user && (
<nav className="flex items-center gap-1 overflow-x-auto">
{navLink("/", "Previews")}
{navLink("/", "Overview")}
{navLink("/previews", "Previews")}
{navLink("/repos", "Repos")}
{navLink("/settings", "Settings")}
{user.isAdmin && navLink("/admin", "Admin")}
@@ -50,13 +57,33 @@ export function Layout({ children }: { children: React.ReactNode }) {
)}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={toggle}
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800 text-lg"
title="Toggle theme"
<div
className="inline-flex items-center rounded-lg border border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800 p-1"
role="group"
aria-label="Theme preference"
>
{dark ? "☀️" : "🌙"}
</button>
{themeOptions.map(({ value, label, Icon }) => {
const selected = theme === value;
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
aria-label={`${label} theme`}
aria-pressed={selected}
title={`${label} theme`}
className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-slate-900 ${
selected
? "bg-white dark:bg-slate-700 text-blue-700 dark:text-blue-300 shadow-sm"
: "text-gray-500 dark:text-slate-400 hover:text-gray-900 dark:hover:text-slate-100"
}`}
>
<Icon size={15} aria-hidden="true" />
<span className="hidden lg:inline">{label}</span>
</button>
);
})}
</div>
{user && (
<button
onClick={handleLogout}
@@ -74,7 +101,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
</main>
<footer className="border-t border-gray-200 dark:border-slate-700 py-4 text-center text-xs text-gray-500 dark:text-slate-400">
PR Previews self-hosted preview environments for every pull request.{" "}
PR Previews self-hosted preview environments for every pull request. {" "}
<Link to="/privacy" className="underline hover:text-gray-700 dark:hover:text-slate-200">Privacy Policy</Link>
</footer>
</div>
+27 -11
View File
@@ -1,18 +1,34 @@
import React, { useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
function stripAnsi(str: string): string {
return str.replace(/\x1B\[[\d;]*[mGKHFJsu]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
}
const TIMESTAMP_RE = /^(\[\d{2}:\d{2}:\d{2}\])\s(.*)$/;
function renderLogLine(line: string, idx: number) {
if (line.startsWith("--- ") && (line.includes("Redeploy") || line.includes("truncated"))) {
return (
<span key={idx} className="text-slate-400 dark:text-slate-500 italic block">
{line}
</span>
);
}
return <span key={idx} className="block">{stripAnsi(line)}</span>;
const match = line.match(TIMESTAMP_RE);
const ts = match ? match[1] : null;
const content = match ? match[2] : line;
const isMarker =
content.startsWith("--- ") &&
(content.includes("Redeploy") || content.includes("truncated"));
return (
<span
key={idx}
className={`block ${isMarker ? "text-slate-500 dark:text-slate-500 italic" : ""}`}
>
{ts && (
<span className="text-slate-400 dark:text-slate-600 select-none mr-2">
{ts}
</span>
)}
{stripAnsi(content)}
</span>
);
}
interface Props {
@@ -36,7 +52,7 @@ export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Prop
return (
<div className="relative">
<div
className="log-viewer bg-gray-950 dark:bg-black text-green-400 rounded-lg p-4 overflow-auto border border-gray-800"
className="log-viewer bg-slate-50 dark:bg-black text-slate-800 dark:text-green-400 rounded-lg p-4 overflow-auto border border-slate-200 dark:border-gray-800 shadow-inner"
style={{ maxHeight }}
onScroll={(e) => {
const el = e.currentTarget;
@@ -52,9 +68,9 @@ export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Prop
{!pinned && (
<button
onClick={() => { setPinned(true); endRef.current?.scrollIntoView({ behavior: "smooth" }); }}
className="absolute bottom-4 right-4 text-xs bg-blue-600 text-white px-2 py-1 rounded shadow hover:bg-blue-700"
className="absolute bottom-4 right-4 inline-flex items-center gap-1 text-xs bg-blue-600 text-white px-2 py-1 rounded shadow hover:bg-blue-700"
>
Jump to bottom
<ArrowDown size={14} /> Jump to bottom
</button>
)}
</div>
+14 -10
View File
@@ -1,21 +1,25 @@
import React from "react";
import { Loader2, CheckCircle2, XCircle, CircleSlash, type LucideIcon } from "lucide-react";
type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED";
const CONFIG: Record<Status, { label: string; icon: string; cls: string }> = {
PROVISIONING: { label: "Provisioning", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
BUILDING: { label: "Building", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
RUNNING: { label: "Running", icon: "🟢", cls: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
FAILED: { label: "Failed", icon: "🔴", cls: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
STOPPED: { label: "Stopped", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
IGNORED: { label: "Ignored", icon: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
const CONFIG: Record<Status, { label: string; icon: LucideIcon; spin?: boolean; cls: string }> = {
PROVISIONING: { label: "Provisioning", icon: Loader2, spin: true, cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
BUILDING: { label: "Building", icon: Loader2, spin: true, cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
RUNNING: { label: "Running", icon: CheckCircle2, cls: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
FAILED: { label: "Failed", icon: XCircle, cls: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
STOPPED: { label: "Stopped", icon: CircleSlash, cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
IGNORED: { label: "Ignored", icon: CircleSlash, cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
};
export function StatusBadge({ status }: { status: Status }) {
export function StatusBadge({ status, reason }: { status: Status; reason?: string | null }) {
const cfg = CONFIG[status] || CONFIG.STOPPED;
const Icon = cfg.icon;
const label = status === "STOPPED" && reason ? `${cfg.label}: ${reason}` : cfg.label;
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.cls}`}>
<span>{cfg.icon}</span> {cfg.label}
<span className={`inline-flex max-w-56 items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.cls}`} title={label}>
<Icon size={12} className={`shrink-0 ${cfg.spin ? "animate-spin" : ""}`} />
<span className="truncate">{label}</span>
</span>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { useEffect } from "react";
const APP_TITLE = "PR Previews";
export function formatPageTitle(title?: string | null) {
const trimmed = title?.trim();
return trimmed ? `${trimmed} | ${APP_TITLE}` : APP_TITLE;
}
export function usePageTitle(title?: string | null) {
useEffect(() => {
document.title = formatPageTitle(title);
}, [title]);
}
+53 -11
View File
@@ -1,16 +1,58 @@
import { useState, useEffect } from "react";
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
export function useTheme() {
const [dark, setDark] = useState(() => {
const stored = localStorage.getItem("pp-theme");
if (stored) return stored === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
export type ThemePreference = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";
interface ThemeContextValue {
theme: ThemePreference;
resolvedTheme: ResolvedTheme;
setTheme: (theme: ThemePreference) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const THEME_STORAGE_KEY = "pp-theme";
function getSystemTheme(): ResolvedTheme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getInitialTheme(): ThemePreference {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
}
function resolveTheme(theme: ThemePreference): ResolvedTheme {
return theme === "system" ? getSystemTheme() : theme;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<ThemePreference>(getInitialTheme);
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(getInitialTheme()));
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
localStorage.setItem("pp-theme", dark ? "dark" : "light");
}, [dark]);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateResolvedTheme = () => setResolvedTheme(theme === "system" ? (mediaQuery.matches ? "dark" : "light") : theme);
return { dark, toggle: () => setDark(d => !d) };
updateResolvedTheme();
localStorage.setItem(THEME_STORAGE_KEY, theme);
if (theme === "system") {
mediaQuery.addEventListener("change", updateResolvedTheme);
return () => mediaQuery.removeEventListener("change", updateResolvedTheme);
}
}, [theme]);
useEffect(() => {
document.documentElement.classList.toggle("dark", resolvedTheme === "dark");
}, [resolvedTheme]);
const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme]);
return React.createElement(ThemeContext.Provider, { value }, children);
}
export function useTheme() {
const theme = useContext(ThemeContext);
if (!theme) throw new Error("useTheme must be used within ThemeProvider");
return theme;
}
+23
View File
@@ -0,0 +1,23 @@
export const DEFAULT_INSTANCE_TYPE = "t3.medium";
export const INSTANCE_TYPES = [
{ value: "t3.medium", label: "t3.medium", cost: "$0.042/hr" },
{ value: "t3.large", label: "t3.large", cost: "$0.083/hr" },
{ value: "t3a.medium", label: "t3a.medium", cost: "$0.038/hr" },
{ value: "t3a.large", label: "t3a.large", cost: "$0.075/hr" },
{ value: "t4g.medium", label: "t4g.medium", cost: "$0.034/hr" },
{ value: "t4g.large", label: "t4g.large", cost: "$0.067/hr" },
{ value: "m5.large", label: "m5.large", cost: "$0.096/hr" },
{ value: "c5.large", label: "c5.large", cost: "$0.085/hr" },
];
export function normalizeInstanceType(value?: string | null, emptyFallback = DEFAULT_INSTANCE_TYPE): string {
const raw = (value || "").trim();
if (!raw) return emptyFallback;
if (raw.startsWith("t2.")) return DEFAULT_INSTANCE_TYPE;
return raw;
}
export function isPresetInstanceType(value?: string | null): boolean {
return INSTANCE_TYPES.some(t => t.value === value);
}
+21 -7
View File
@@ -5,6 +5,9 @@ import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { StatusBadge } from "../components/StatusBadge";
import { Link } from "react-router-dom";
import { Ban, RefreshCw } from "lucide-react";
import { INSTANCE_TYPES, isPresetInstanceType, normalizeInstanceType } from "../lib/instanceTypes";
import { usePageTitle } from "../hooks/usePageTitle";
interface EditUserForm {
id: number;
@@ -18,6 +21,7 @@ interface EditUserForm {
export function Admin() {
const { user } = useAuth();
const [tab, setTab] = useState<"users" | "settings" | "previews">("users");
usePageTitle(`Admin ${tab[0].toUpperCase()}${tab.slice(1)}`);
const [users, setUsers] = useState<any[]>([]);
const [settings, setSettings] = useState<any>(null);
const [previews, setPreviews] = useState<any[]>([]);
@@ -36,7 +40,7 @@ export function Admin() {
};
const loadSettings = async () => {
const res = await api.admin.getSettings();
if (res.ok) setSettings(res.data);
if (res.ok) setSettings({ ...res.data, defaultInstanceType: normalizeInstanceType(res.data?.defaultInstanceType) });
};
const loadPreviews = async () => {
const res = await api.admin.listPreviews();
@@ -51,7 +55,7 @@ export function Admin() {
if (!user?.isAdmin) return (
<div className="text-center py-20">
<div className="text-5xl mb-4">🚫</div>
<Ban size={48} className="mx-auto mb-4 text-red-500 dark:text-red-400" />
<h2 className="text-xl font-semibold">Access Denied</h2>
</div>
);
@@ -244,9 +248,19 @@ export function Admin() {
<h2 className="font-semibold mb-4">Global Settings</h2>
<form onSubmit={handleSaveSettings} className="space-y-4">
<Field label="Default EC2 Instance Type">
<input type="text" value={settings.defaultInstanceType}
onChange={e => setSettings((s: any) => ({ ...s, defaultInstanceType: e.target.value }))}
className={inputCls} />
<select
value={isPresetInstanceType(settings.defaultInstanceType) ? settings.defaultInstanceType : "custom"}
onChange={e => setSettings((s: any) => ({ ...s, defaultInstanceType: e.target.value === "custom" ? "" : e.target.value }))}
className={inputCls}
>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} - {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{!isPresetInstanceType(settings.defaultInstanceType) && (
<input type="text" value={settings.defaultInstanceType}
onChange={e => setSettings((s: any) => ({ ...s, defaultInstanceType: normalizeInstanceType(e.target.value, "") }))}
className={`${inputCls} mt-1`} placeholder="Custom instance type" />
)}
</Field>
<Field label="Max Concurrent Instances Per User">
<input type="number" value={settings.maxConcurrentInstancesPerUser}
@@ -284,7 +298,7 @@ export function Admin() {
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
<h2 className="font-semibold">All Previews</h2>
<button onClick={loadPreviews} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> Refresh</button>
<button onClick={loadPreviews} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"><RefreshCw size={14} /> Refresh</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -306,7 +320,7 @@ export function Admin() {
<Link to={`/previews/${p.id}`} className="font-medium hover:text-blue-600 dark:hover:text-blue-400">PR #{p.prNumber}</Link>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{p.user?.username}</td>
<td className="px-4 py-3"><StatusBadge status={p.status} /></td>
<td className="px-4 py-3"><StatusBadge status={p.status} reason={p.stopReason} /></td>
<td className="px-4 py-3">
{p.instanceIp ? (
<a href={`http://${p.instanceIp}:${p.port}`} target="_blank" rel="noreferrer"
+104 -49
View File
@@ -1,9 +1,11 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../services/api";
import { api, formatUsd } from "../services/api";
import { StatusBadge } from "../components/StatusBadge";
import { motion } from "motion/react";
import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { Settings2, Search, RefreshCw, ArrowRight, ExternalLink, ChevronDown, ChevronRight } from "lucide-react";
interface Preview {
id: number;
@@ -11,6 +13,7 @@ interface Preview {
prTitle: string;
commitSha: string;
status: string;
stopReason: string | null;
instanceIp: string | null;
port: number;
createdAt: string;
@@ -18,12 +21,67 @@ interface Preview {
lastActivityAt: string;
repoOwner: string;
repoName: string;
instanceType: string | null;
costUsd: number;
}
// Previews still doing something get shown by default; the rest are tucked away.
const ACTIVE_STATUSES = ["PROVISIONING", "BUILDING", "RUNNING"];
function PreviewCard({ p, i }: { p: Preview; i: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(i * 0.04, 0.4) }}
>
<Link
to={`/previews/${p.id}`}
className="block bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
>
<div className="flex items-start justify-between mb-2">
<div>
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p>
<p className="font-semibold text-sm mt-0.5 line-clamp-1">PR #{p.prNumber}: {p.prTitle}</p>
</div>
<StatusBadge status={p.status as any} reason={p.stopReason} />
</div>
<p className="text-xs text-gray-500 dark:text-slate-400 mb-2">
Commit: <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">{p.commitSha.slice(0, 8)}</code>
</p>
{p.status === "RUNNING" && p.instanceIp && (
<a
href={`http://${p.instanceIp}:${p.port}`}
target="_blank"
rel="noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline"
>
<ExternalLink size={12} /> http://{p.instanceIp}:{p.port}
</a>
)}
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-gray-400 dark:text-slate-500">
Updated {new Date(p.updatedAt).toLocaleString()}
</p>
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 tabular-nums" title="Estimated EC2 cost">
{formatUsd(p.costUsd)}
</span>
</div>
</Link>
</motion.div>
);
}
export function Dashboard() {
const [previews, setPreviews] = useState<Preview[]>([]);
const [loading, setLoading] = useState(true);
const [showOld, setShowOld] = useState(false);
const { user } = useAuth();
usePageTitle("Previews");
const load = async () => {
const res = await api.previews.list();
@@ -40,7 +98,7 @@ export function Dashboard() {
if (!user?.setupComplete && !loading) {
return (
<div className="text-center py-20">
<div className="text-5xl mb-4"></div>
<Settings2 size={48} className="mx-auto mb-4 text-gray-400 dark:text-slate-500" />
<h2 className="text-2xl font-bold mb-2">Setup Required</h2>
<p className="text-gray-600 dark:text-slate-400 mb-6">Configure your Gitea and AWS credentials to get started.</p>
<Link
@@ -53,12 +111,23 @@ export function Dashboard() {
);
}
const active = previews.filter(p => ACTIVE_STATUSES.includes(p.status));
const old = previews.filter(p => !ACTIVE_STATUSES.includes(p.status));
const totalCost = previews.reduce((sum, p) => sum + (p.costUsd || 0), 0);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Previews</h1>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
Refresh
<div>
<h1 className="text-2xl font-bold">Previews</h1>
{previews.length > 0 && (
<p className="text-sm text-gray-500 dark:text-slate-400">
Estimated total EC2 cost: <span className="font-medium text-gray-700 dark:text-slate-200">{formatUsd(totalCost)}</span>
</p>
)}
</div>
<button onClick={load} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<RefreshCw size={14} /> Refresh
</button>
</div>
@@ -70,59 +139,45 @@ export function Dashboard() {
</div>
) : previews.length === 0 ? (
<div className="text-center py-20">
<div className="text-5xl mb-4">🔍</div>
<Search size={48} className="mx-auto mb-4 text-gray-400 dark:text-slate-500" />
<h2 className="text-xl font-semibold mb-2">No previews yet</h2>
<p className="text-gray-500 dark:text-slate-400 mb-4">
Enable a repo and open a pull request to create your first preview.
</p>
<Link to="/repos" className="text-blue-600 dark:text-blue-400 hover:underline">
Configure repos
<Link to="/repos" className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline">
Configure repos <ArrowRight size={14} />
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{previews.map((p, i) => (
<motion.div
key={p.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
>
<Link
to={`/previews/${p.id}`}
className="block bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all"
<>
{active.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{active.map((p, i) => <PreviewCard key={p.id} p={p} i={i} />)}
</div>
) : (
<div className="text-center py-12 border border-dashed border-gray-200 dark:border-slate-700 rounded-xl">
<p className="text-gray-500 dark:text-slate-400">No active previews right now.</p>
</div>
)}
{old.length > 0 && (
<div className="mt-8">
<button
onClick={() => setShowOld(v => !v)}
className="inline-flex items-center gap-1.5 text-sm font-medium text-gray-600 dark:text-slate-300 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div>
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p>
<p className="font-semibold text-sm mt-0.5 line-clamp-1">PR #{p.prNumber}: {p.prTitle}</p>
</div>
<StatusBadge status={p.status as any} />
{showOld ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{showOld ? "Hide" : "Show"} stopped &amp; old previews ({old.length})
</button>
{showOld && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
{old.map((p, i) => <PreviewCard key={p.id} p={p} i={i} />)}
</div>
<p className="text-xs text-gray-500 dark:text-slate-400 mb-2">
Commit: <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">{p.commitSha.slice(0, 8)}</code>
</p>
{p.status === "RUNNING" && p.instanceIp && (
<a
href={`http://${p.instanceIp}:${p.port}`}
target="_blank"
rel="noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline"
>
🟢 http://{p.instanceIp}:{p.port}
</a>
)}
<p className="text-xs text-gray-400 dark:text-slate-500 mt-2">
Updated {new Date(p.updatedAt).toLocaleString()}
</p>
</Link>
</motion.div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
);
+5 -2
View File
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify";
import { motion } from "motion/react";
import { Rocket, Loader2 } from "lucide-react";
export function Login() {
const [username, setUsername] = useState("");
@@ -13,6 +15,7 @@ export function Login() {
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
const { refresh } = useAuth();
const navigate = useNavigate();
usePageTitle(needsSetup ? "Create Admin Account" : "Sign In");
useEffect(() => {
api.auth.setupStatus().then(res => {
@@ -46,7 +49,7 @@ export function Login() {
if (needsSetup === null) {
return <div className="min-h-screen flex items-center justify-center">
<div className="text-2xl animate-spin"></div>
<Loader2 size={32} className="animate-spin text-blue-600 dark:text-blue-400" />
</div>;
}
@@ -58,7 +61,7 @@ export function Login() {
className="bg-white dark:bg-slate-800 shadow-xl rounded-2xl p-8 w-full max-w-sm"
>
<div className="text-center mb-6">
<div className="text-4xl mb-2">🚀</div>
<Rocket size={40} className="mx-auto mb-2 text-blue-600 dark:text-blue-400" />
<h1 className="text-2xl font-bold">PR Previews</h1>
{needsSetup ? (
<p className="text-sm text-blue-600 dark:text-blue-400 mt-1 font-medium">Create your admin account</p>
+217
View File
@@ -0,0 +1,217 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api, formatUsd } from "../services/api";
import { StatusBadge } from "../components/StatusBadge";
import { motion } from "motion/react";
import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import {
Settings2,
RefreshCw,
ArrowRight,
ExternalLink,
Rocket,
Users,
FolderGit2,
Server,
Activity,
DollarSign,
type LucideIcon,
} from "lucide-react";
interface RecentPreview {
id: number;
prNumber: number;
prTitle: string;
status: string;
stopReason: string | null;
instanceIp: string | null;
port: number;
updatedAt: string;
repoOwner: string;
repoName: string;
}
interface Stats {
totalUsers: number;
totalRepos: number;
enabledRepos: number;
totalPreviews: number;
activePreviews: number;
activeInstances: number;
totalCostUsd: number;
hourlyBurnUsd: number;
byStatus: Record<string, number>;
recentPreviews: RecentPreview[];
}
const STATUS_ORDER = ["RUNNING", "BUILDING", "PROVISIONING", "FAILED", "STOPPED", "IGNORED"];
function StatCard({
icon: Icon,
label,
value,
sub,
delay,
}: {
icon: LucideIcon;
label: string;
value: number | string;
sub?: string;
delay: number;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4"
>
<div className="flex items-center gap-2 text-gray-500 dark:text-slate-400 mb-2">
<Icon size={16} />
<span className="text-xs font-medium uppercase tracking-wide">{label}</span>
</div>
<p className="text-3xl font-bold tabular-nums">{value}</p>
{sub && <p className="text-xs text-gray-500 dark:text-slate-400 mt-1">{sub}</p>}
</motion.div>
);
}
export function Overview() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
usePageTitle("Overview");
const load = async () => {
const res = await api.stats.get();
if (res.ok) setStats(res.data || null);
setLoading(false);
};
useEffect(() => { load(); }, []);
useEffect(() => {
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, []);
if (!user?.setupComplete && !loading) {
return (
<div className="text-center py-20">
<Settings2 size={48} className="mx-auto mb-4 text-gray-400 dark:text-slate-500" />
<h2 className="text-2xl font-bold mb-2">Setup Required</h2>
<p className="text-gray-600 dark:text-slate-400 mb-6">Configure your Gitea and AWS credentials to get started.</p>
<Link
to="/settings"
className="inline-flex px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Go to Settings
</Link>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Overview</h1>
<p className="text-sm text-gray-500 dark:text-slate-400">Instance-wide activity across PR Previews.</p>
</div>
<button onClick={load} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<RefreshCw size={14} /> Refresh
</button>
</div>
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-28 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />
))}
</div>
) : stats ? (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard icon={Rocket} label="Previews" value={stats.totalPreviews} sub={`${stats.activePreviews} active`} delay={0} />
<StatCard icon={Server} label="Live Instances" value={stats.activeInstances} sub="running EC2s" delay={0.04} />
<StatCard icon={FolderGit2} label="Repos" value={stats.totalRepos} sub={`${stats.enabledRepos} enabled`} delay={0.08} />
<StatCard icon={Users} label="Users" value={stats.totalUsers} delay={0.12} />
<StatCard
icon={DollarSign}
label="Est. Cost"
value={formatUsd(stats.totalCostUsd)}
sub={stats.hourlyBurnUsd > 0 ? `${formatUsd(stats.hourlyBurnUsd)}/hr now` : "no live spend"}
delay={0.16}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Status breakdown */}
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-4">
<Activity size={16} className="text-gray-500 dark:text-slate-400" />
<h2 className="font-semibold">By status</h2>
</div>
{STATUS_ORDER.some(s => stats.byStatus[s]) ? (
<ul className="space-y-2">
{STATUS_ORDER.filter(s => stats.byStatus[s]).map(s => (
<li key={s} className="flex items-center justify-between">
<StatusBadge status={s as any} />
<span className="text-sm font-medium tabular-nums">{stats.byStatus[s]}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500 dark:text-slate-400">No previews yet.</p>
)}
</div>
{/* Recent activity */}
<div className="lg:col-span-2 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">Recent activity</h2>
<Link to="/previews" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
All previews <ArrowRight size={14} />
</Link>
</div>
{stats.recentPreviews.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-slate-400">
No previews yet. Enable a repo and open a pull request to get started.
</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-slate-700">
{stats.recentPreviews.map(p => (
<li key={p.id}>
<Link to={`/previews/${p.id}`} className="flex items-center justify-between gap-3 py-2.5 -mx-2 px-2 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700/40 transition-colors">
<div className="min-w-0">
<p className="text-xs text-gray-500 dark:text-slate-400 truncate">{p.repoOwner}/{p.repoName}</p>
<p className="text-sm font-medium truncate">PR #{p.prNumber}: {p.prTitle}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{p.status === "RUNNING" && p.instanceIp && (
<a
href={`http://${p.instanceIp}:${p.port}`}
target="_blank"
rel="noreferrer"
onClick={e => e.stopPropagation()}
className="hidden sm:inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 hover:underline"
>
<ExternalLink size={12} /> open
</a>
)}
<StatusBadge status={p.status as any} reason={p.stopReason} />
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500 dark:text-slate-400">Failed to load stats.</p>
)}
</div>
);
}
+95 -8
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { api, openLogsWs } from "../services/api";
import { api, openLogsWs, openAppLogsWs, formatUsd } from "../services/api";
import { StatusBadge } from "../components/StatusBadge";
import { LogViewer } from "../components/LogViewer";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify";
import { ArrowLeft, CheckCircle2, RefreshCw } from "lucide-react";
interface Job {
id: number;
@@ -22,6 +24,7 @@ interface Preview {
prTitle: string;
commitSha: string;
status: string;
stopReason: string | null;
instanceIp: string | null;
port: number;
logs: string;
@@ -31,6 +34,9 @@ interface Preview {
lastActivityAt: string;
repoOwner: string;
repoName: string;
instanceType: string | null;
costUsd: number;
costRateUsd: number;
jobs: Job[];
}
@@ -40,9 +46,13 @@ export function PreviewDetail() {
const navigate = useNavigate();
const [preview, setPreview] = useState<Preview | null>(null);
const [logs, setLogs] = useState("");
const [appLogs, setAppLogs] = useState("");
const [logTab, setLogTab] = useState<"deploy" | "app">("deploy");
const [loading, setLoading] = useState(true);
const [confirmStop, setConfirmStop] = useState(false);
const [stopping, setStopping] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
usePageTitle(preview ? `PR #${preview.prNumber} | ${preview.repoOwner}/${preview.repoName}` : `Preview ${previewId}`);
const loadPreview = useCallback(async () => {
const res = await api.previews.get(previewId);
@@ -50,7 +60,7 @@ export function PreviewDetail() {
setPreview(res.data as Preview);
setLogs(res.data.logs || "");
} else if (res.status === 404) {
navigate("/");
navigate("/previews");
}
setLoading(false);
}, [previewId, navigate]);
@@ -73,6 +83,19 @@ export function PreviewDetail() {
return () => { try { ws.close(); } catch {} };
}, [preview?.status, previewId]);
// Live app-process logs are only meaningful once the app is actually running.
// Reset and re-open the stream whenever the running instance changes.
useEffect(() => {
if (preview?.status !== "RUNNING") return;
setAppLogs("");
const ws = openAppLogsWs(previewId, (msg) => {
if (msg.type === "append") setAppLogs(prev => prev + msg.text);
});
return () => { try { ws.close(); } catch {} };
}, [preview?.status, preview?.instanceIp, previewId]);
const handleStop = async () => {
setStopping(true);
const res = await api.previews.stop(previewId);
@@ -83,6 +106,15 @@ export function PreviewDetail() {
await loadPreview();
};
const handleRebuild = async () => {
setRebuilding(true);
const res = await api.previews.rebuild(previewId);
setRebuilding(false);
if (res.ok) toast.success("Rebuild job enqueued");
else toast.error(res.message || "Failed to enqueue rebuild");
await loadPreview();
};
if (loading) {
return (
<div className="space-y-4 animate-pulse">
@@ -108,8 +140,8 @@ export function PreviewDetail() {
/>
<div>
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-2 inline-block">
Back to Previews
<Link to="/previews" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-2">
<ArrowLeft size={14} /> Back to Previews
</Link>
<div className="flex items-start justify-between gap-4">
<div>
@@ -119,7 +151,17 @@ export function PreviewDetail() {
<p className="text-gray-600 dark:text-slate-300 mt-1">{preview.prTitle}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={preview.status as any} />
<StatusBadge status={preview.status as any} reason={preview.stopReason} />
{preview.status !== "IGNORED" && (
<button
onClick={handleRebuild}
disabled={rebuilding}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/60 rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw size={14} className={rebuilding ? "animate-spin" : ""} />
Rebuild
</button>
)}
{preview.status !== "STOPPED" && preview.status !== "IGNORED" && (
<button
onClick={() => setConfirmStop(true)}
@@ -138,11 +180,22 @@ export function PreviewDetail() {
<InfoCard label="Port" value={String(preview.port)} />
<InfoCard label="Created" value={new Date(preview.createdAt).toLocaleDateString()} />
<InfoCard label="Last Activity" value={new Date(preview.lastActivityAt).toLocaleString()} />
{preview.status === "STOPPED" && (
<InfoCard label="Stop Reason" value={preview.stopReason || "Stopped"} />
)}
<InfoCard
label="Est. Cost"
value={
formatUsd(preview.costUsd) +
(preview.costRateUsd > 0 ? ` (${formatUsd(preview.costRateUsd)}/hr)` : "")
}
/>
<InfoCard label="Instance" value={preview.instanceType || "—"} mono />
</div>
{preview.status === "RUNNING" && preview.instanceIp && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl p-4">
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-1">🟢 Preview Live</p>
<p className="inline-flex items-center gap-1.5 text-sm font-medium text-green-800 dark:text-green-300 mb-1"><CheckCircle2 size={16} /> Preview Live</p>
<a
href={`http://${preview.instanceIp}:${preview.port}`}
target="_blank"
@@ -155,8 +208,42 @@ export function PreviewDetail() {
)}
<div>
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer logs={logs} autoScroll maxHeight="600px" />
<div className="flex items-center gap-2 mb-3">
<h2 className="text-lg font-semibold mr-2">Logs</h2>
<button
onClick={() => setLogTab("deploy")}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
logTab === "deploy"
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700"
}`}
>
Deploy
</button>
<button
onClick={() => setLogTab("app")}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
logTab === "app"
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700"
}`}
>
App
</button>
</div>
{logTab === "deploy" ? (
<LogViewer logs={logs} autoScroll maxHeight="600px" />
) : preview.status === "RUNNING" ? (
<LogViewer
logs={appLogs || "Connecting to app log stream…"}
autoScroll
maxHeight="600px"
/>
) : (
<div className="bg-slate-50 dark:bg-black rounded-lg p-4 border border-slate-200 dark:border-gray-800 text-sm text-gray-500 dark:text-slate-400">
App logs are only available while the preview is live.
</div>
)}
</div>
<div>
+135 -33
View File
@@ -1,9 +1,75 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../services/api";
import { usePageTitle } from "../hooks/usePageTitle";
import {
ArrowLeft,
Clock3,
Database,
KeyRound,
Mail,
Server,
Share2,
ShieldCheck,
UserCheck,
} from "lucide-react";
const policySections = [
{
title: "Information We Process",
Icon: Database,
items: [
"Account information, including your username and a one-way hashed password.",
"Gitea connection details, including your Gitea username, instance URL, and Personal Access Token.",
"AWS credentials and region settings used to create and manage preview infrastructure.",
"Repository and pull request metadata, such as repository name, PR number, title, and commit SHA.",
"Preview deployment logs, job status, preview URLs, EC2 instance identifiers, and related operational metadata.",
"Ephemeral SSH private keys generated for preview instances.",
],
},
{
title: "How Information Is Used",
Icon: KeyRound,
items: [
"Gitea credentials are used to register and maintain webhooks, clone repositories, read pull request metadata, and post preview status comments.",
"AWS credentials are used to provision, tag, inspect, and terminate EC2 resources for previews in your AWS account.",
"Deployment logs and metadata are used to show preview status, diagnose failed builds, and support administrative operation of this instance.",
"Webhook secrets are used to verify that incoming webhook requests were sent by the configured Gitea instance.",
],
},
];
const detailSections = [
{
title: "Security",
Icon: ShieldCheck,
body: "Sensitive credentials, including Gitea tokens, AWS access keys, AWS secret access keys, and SSH private keys, are encrypted at rest with AES-256-GCM. Passwords are stored as bcrypt hashes. Webhook signatures are verified before webhook payloads are processed.",
},
{
title: "Infrastructure",
Icon: Server,
body: "Preview instances are launched in your AWS account using the credentials you provide. PR Previews manages only the resources required to operate previews, including EC2 instances, security groups, and temporary SSH keys. Preview instances are terminated when a pull request is closed, a preview is manually stopped, or the configured inactivity timeout is reached.",
},
{
title: "Retention",
Icon: Clock3,
body: "Preview records, logs, and metadata are retained according to the retention period configured by the instance administrator. By default, stopped and failed preview records are eligible for cleanup after 30 days. Ephemeral SSH keys are removed when their associated preview instance is terminated.",
},
{
title: "Data Sharing",
Icon: Share2,
body: "This instance does not include third-party analytics, advertising trackers, or external data sharing features. Data is processed by this PR Previews instance, the configured Gitea instance, and AWS services in the account used for preview infrastructure.",
},
{
title: "Your Responsibilities",
Icon: UserCheck,
body: "Users are responsible for providing credentials with appropriate scopes and for managing access to the Gitea repositories and AWS accounts connected to this instance. Administrators are responsible for configuring retention, access control, and operational policies for this deployment.",
},
];
export function Privacy() {
const [contactEmail, setContactEmail] = useState<string | null>(null);
usePageTitle("Privacy Policy");
useEffect(() => {
api.admin.getSettings().then(res => {
@@ -12,43 +78,79 @@ export function Privacy() {
}, []);
return (
<div className="max-w-2xl prose dark:prose-invert">
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-4 inline-block"> Back</Link>
<h1>Privacy Policy</h1>
<p>This is a self-hosted instance of PR Previews (PP). The following describes what data PP stores and how it is used.</p>
<div className="mx-auto max-w-5xl space-y-6">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">
<ArrowLeft size={16} aria-hidden="true" />
Back
</Link>
<h2>What We Store</h2>
<ul>
<li>Your username and hashed password (bcrypt).</li>
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256-GCM.</li>
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256-GCM.</li>
<li>Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.</li>
<li>SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).</li>
</ul>
<section className="border-b border-gray-200 dark:border-slate-700 pb-6">
<div className="inline-flex items-center gap-2 rounded-full bg-blue-50 dark:bg-blue-950/40 px-3 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">
<ShieldCheck size={14} aria-hidden="true" />
Self-hosted privacy policy
</div>
<h1 className="mt-4 text-3xl font-bold tracking-tight text-gray-950 dark:text-slate-50 sm:text-4xl">
Privacy Policy
</h1>
<p className="mt-3 max-w-3xl text-base leading-7 text-gray-600 dark:text-slate-300">
PR Previews is a self-hosted preview deployment service for Gitea pull requests. This policy explains what information this instance processes, why it is needed, and how it is protected.
</p>
</section>
<h2>How We Use Your Data</h2>
<ul>
<li>Your Gitea PAT is used solely to register webhooks, clone repositories, and post preview status comments on PRs.</li>
<li>Your AWS credentials are used solely to provision EC2 instances for previews in your own AWS account.</li>
<li>PP does not have access to data on EC2 instances beyond what it deploys.</li>
<li>No data is shared with third parties.</li>
</ul>
<div className="grid gap-4 lg:grid-cols-2">
{policySections.map(({ title, Icon, items }) => (
<section key={title} className="rounded-xl border border-gray-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-800">
<div className="mb-4 flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300">
<Icon size={20} aria-hidden="true" />
</span>
<h2 className="text-lg font-semibold text-gray-950 dark:text-slate-50">{title}</h2>
</div>
<ul className="space-y-2.5 text-sm leading-6 text-gray-600 dark:text-slate-300">
{items.map(item => (
<li key={item} className="flex gap-2">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 dark:bg-blue-400" />
<span>{item}</span>
</li>
))}
</ul>
</section>
))}
</div>
<h2>Data Retention</h2>
<p>Preview records (logs, metadata) are retained for the number of days configured by the administrator (default: 30 days after a preview is stopped or failed). You can view this setting in the admin panel.</p>
<div className="grid gap-4 md:grid-cols-2">
{detailSections.map(({ title, Icon, body }) => (
<section key={title} className="rounded-xl border border-gray-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-800">
<div className="mb-3 flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-200">
<Icon size={18} aria-hidden="true" />
</span>
<h2 className="text-base font-semibold text-gray-950 dark:text-slate-50">{title}</h2>
</div>
<p className="text-sm leading-6 text-gray-600 dark:text-slate-300">{body}</p>
</section>
))}
</div>
<h2>EC2 Instances</h2>
<p>Preview instances are launched in your own AWS account using your credentials. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances beyond what is captured in preview logs.</p>
<h2>Analytics &amp; Tracking</h2>
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p>
<h2>Contact</h2>
{contactEmail ? (
<p>For questions or concerns, contact the instance administrator at <a href={`mailto:${contactEmail}`} className="text-blue-600 dark:text-blue-400 underline">{contactEmail}</a>.</p>
) : (
<p>For questions or concerns, contact the instance administrator.</p>
)}
<section className="rounded-xl border border-blue-200 bg-blue-50 p-5 dark:border-blue-900/70 dark:bg-blue-950/30">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-blue-700 dark:bg-slate-900 dark:text-blue-300">
<Mail size={20} aria-hidden="true" />
</span>
<div>
<h2 className="text-base font-semibold text-gray-950 dark:text-slate-50">Contact</h2>
{contactEmail ? (
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300">
For privacy questions or requests related to this instance, contact the instance administrator at <a href={`mailto:${contactEmail}`} className="font-medium text-blue-700 underline dark:text-blue-300">{contactEmail}</a>.
</p>
) : (
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-slate-300">
For privacy questions or requests related to this instance, contact the instance administrator.
</p>
)}
</div>
</div>
</section>
</div>
);
}
+412
View File
@@ -0,0 +1,412 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { api } from "../services/api";
import { toast } from "react-toastify";
import { ArrowLeft, X, Plus } from "lucide-react";
import { DEFAULT_INSTANCE_TYPE, INSTANCE_TYPES, isPresetInstanceType, normalizeInstanceType } from "../lib/instanceTypes";
import { usePageTitle } from "../hooks/usePageTitle";
function defaultConfig() {
return {
instanceType: DEFAULT_INSTANCE_TYPE,
inactivityHours: 12,
port: 3000,
denyList: [] as string[],
disabledCommands: [] as string[],
envVars: {} as Record<string, string>,
preinstallTools: [] as string[],
nodeVersion: "lts/*",
useDockerCompose: false,
composeFilePath: "docker-compose.yml",
aptPackages: [] as string[],
setupCommands: [] as string[],
buildCommands: [] as string[],
postBuildCommands: [] as string[],
runCommand: "",
};
}
function normalizeConfig(raw: any) {
const base = defaultConfig();
if (!raw) return base;
return {
instanceType: normalizeInstanceType(raw.instanceType ?? base.instanceType),
inactivityHours: raw.inactivityHours ?? base.inactivityHours,
port: raw.port ?? base.port,
denyList: Array.isArray(raw.denyList) ? raw.denyList : [],
disabledCommands: Array.isArray(raw.disabledCommands) ? raw.disabledCommands : [],
envVars: raw.envVars && typeof raw.envVars === "object" ? raw.envVars : {},
preinstallTools: Array.isArray(raw.preinstallTools) ? raw.preinstallTools : [],
nodeVersion: raw.nodeVersion ?? base.nodeVersion,
useDockerCompose: Boolean(raw.useDockerCompose),
composeFilePath: raw.composeFilePath ?? base.composeFilePath,
aptPackages: Array.isArray(raw.aptPackages) ? raw.aptPackages : [],
setupCommands: Array.isArray(raw.setupCommands) ? raw.setupCommands : [],
buildCommands: Array.isArray(raw.buildCommands) ? raw.buildCommands : [],
postBuildCommands: Array.isArray(raw.postBuildCommands) ? raw.postBuildCommands : [],
runCommand: raw.runCommand ?? "",
};
}
function parseAptPackages(value: string) {
return value.trim().split(/\s+/).filter(Boolean);
}
export function RepoConfig() {
const { owner, repo } = useParams<{ owner: string; repo: string }>();
const navigate = useNavigate();
const [config, setConfig] = useState<any>(defaultConfig());
const [aptPackagesInput, setAptPackagesInput] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
usePageTitle(owner && repo ? `Configure ${owner}/${repo}` : "Configure Repo");
useEffect(() => {
let active = true;
(async () => {
if (!owner || !repo) return;
setLoading(true);
const res = await api.repos.getConfig(owner, repo);
if (!active) return;
if (res.ok) {
const nextConfig = normalizeConfig(res.data);
setConfig(nextConfig);
setAptPackagesInput(nextConfig.aptPackages.join(" "));
} else if (res.status === 404) {
// No config saved yet — start from defaults.
setConfig(defaultConfig());
setAptPackagesInput("");
} else {
toast.error(res.message || "Failed to load config");
}
setLoading(false);
})();
return () => { active = false; };
}, [owner, repo]);
const update = (field: string, value: any) => {
setConfig((prev: any) => ({ ...prev, [field]: value }));
};
const togglePreinstall = (tool: string, checked: boolean) => {
setConfig((prev: any) => {
const current = Array.isArray(prev.preinstallTools) ? prev.preinstallTools : [];
const next = checked
? [...new Set([...current, tool])]
: current.filter((item: string) => item !== tool);
return { ...prev, preinstallTools: next };
});
};
const setDockerCompose = (checked: boolean) => {
setConfig((prev: any) => {
const current = Array.isArray(prev.preinstallTools) ? prev.preinstallTools : [];
return {
...prev,
useDockerCompose: checked,
preinstallTools: checked ? [...new Set([...current, "docker"])] : current,
};
});
};
const handleSave = async () => {
if (!owner || !repo) return;
setSaving(true);
const res = await api.repos.saveConfig({
owner,
repo,
...config,
aptPackages: parseAptPackages(aptPackagesInput),
});
setSaving(false);
if (res.ok) {
toast.success("Config saved");
} else {
toast.error(res.message || "Failed to save config");
}
};
if (loading) {
return (
<div className="max-w-3xl space-y-3">
<div className="h-8 w-64 bg-gray-100 dark:bg-slate-800 rounded animate-pulse" />
{[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
</div>
);
}
return (
<div className="max-w-3xl space-y-4">
<div>
<Link to="/repos" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"><ArrowLeft size={14} /> Back to Repos</Link>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Configure Repo</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono">{owner}/{repo}</p>
</div>
</div>
<div className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>Instance Type</label>
<select
value={isPresetInstanceType(config.instanceType) ? config.instanceType : "custom"}
onChange={e => update("instanceType", e.target.value === "custom" ? "" : e.target.value)}
className={inputCls}
>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{!isPresetInstanceType(config.instanceType) && (
<input type="text" value={config.instanceType} placeholder="Custom instance type" className={`${inputCls} mt-1`}
onChange={e => update("instanceType", normalizeInstanceType(e.target.value, ""))} />
)}
</div>
<div>
<label className={labelCls}>App Port (default: 3000)</label>
<input type="number" value={config.port} onChange={e => update("port", Number(e.target.value))} className={inputCls} min={1} max={65535} />
</div>
</div>
<div>
<label className={labelCls}>Inactivity Kill Timer: {config.inactivityHours}h</label>
<input type="range" min={0.5} max={72} step={0.5} value={config.inactivityHours}
onChange={e => update("inactivityHours", Number(e.target.value))}
className="w-full mt-1" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0.5h</span><span>12h</span><span>72h</span>
</div>
</div>
<div>
<label className={labelCls}>Deny List (comma-separated usernames)</label>
<input type="text"
value={config.denyList.join(", ")}
onChange={e => update("denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))}
className={inputCls}
placeholder="dependabot, renovate-bot"
/>
</div>
<div>
<label className={labelCls}>Preinstall Options</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{PREINSTALL_OPTIONS.map(option => {
const checked = (config.preinstallTools || []).includes(option.value);
const locked = option.value === "docker" && config.useDockerCompose;
return (
<label key={option.value} className="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-slate-700 px-3 py-2 text-sm">
<input
type="checkbox"
checked={checked}
disabled={locked}
onChange={e => togglePreinstall(option.value, e.target.checked)}
className="rounded"
/>
<span>{option.label}</span>
</label>
);
})}
</div>
{(config.preinstallTools || []).includes("node") && (
<div className="mt-3">
<label className={labelCls}>Node Version</label>
<input type="text"
value={config.nodeVersion || "lts/*"}
onChange={e => update("nodeVersion", e.target.value)}
className={inputCls}
placeholder="lts/*, 22, 20.11.1"
/>
</div>
)}
</div>
<div>
<label className={labelCls}>Additional Apt Packages (space-separated)</label>
<input type="text"
value={aptPackagesInput}
onChange={e => {
setAptPackagesInput(e.target.value);
update("aptPackages", parseAptPackages(e.target.value));
}}
className={inputCls}
placeholder="ffmpeg libpq-dev"
/>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<input type="checkbox" id="compose" checked={config.useDockerCompose}
onChange={e => setDockerCompose(e.target.checked)} className="rounded" />
<label htmlFor="compose" className="text-sm font-medium">Use Docker Compose</label>
</div>
{config.useDockerCompose ? (
<div>
<label className={labelCls}>Compose File Path</label>
<input type="text" value={config.composeFilePath || "docker-compose.yml"}
onChange={e => update("composeFilePath", e.target.value)}
className={inputCls} placeholder="docker-compose.yml" />
</div>
) : (
<div className="space-y-3">
<CommandList label="Build Commands" value={config.buildCommands}
onChange={v => update("buildCommands", v)} />
<CommandList label="Post-Build Commands (optional)" value={config.postBuildCommands}
onChange={v => update("postBuildCommands", v)} />
<div>
<label className={labelCls}>Run Command</label>
<input type="text" value={config.runCommand || ""}
onChange={e => update("runCommand", e.target.value)}
className={inputCls} placeholder="python app.py, ./server, pnpm start" />
</div>
</div>
)}
</div>
<CommandList label="Setup Commands (run once on first provision)"
value={config.setupCommands}
onChange={v => update("setupCommands", v)} />
<EnvVarsEditor value={config.envVars || {}}
onChange={v => update("envVars", v)} />
<CommandToggles value={config.disabledCommands || []}
onChange={v => update("disabledCommands", v)} />
<div className="flex justify-end gap-2">
<button
onClick={() => navigate("/repos")}
className="px-5 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{saving ? "Saving..." : "Save Config"}
</button>
</div>
</div>
</div>
);
}
function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) {
return (
<div>
<label className={labelCls}>{label}</label>
<div className="space-y-1">
{value.map((cmd, i) => (
<div key={i} className="flex gap-2">
<input type="text" value={cmd}
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }}
className={inputCls} placeholder="make build" />
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"><X size={16} /></button>
</div>
))}
<button onClick={() => onChange([...value, ""])} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<Plus size={14} /> Add command
</button>
</div>
</div>
);
}
const PREINSTALL_OPTIONS: { value: string; label: string }[] = [
{ value: "docker", label: "Docker + Compose" },
{ value: "node", label: "Node.js" },
{ value: "python", label: "Python" },
{ value: "go", label: "Go" },
{ value: "lua", label: "Lua" },
{ value: "build-essential", label: "Build tools" },
];
// Configurable `/pp` commands. `help` is intentionally omitted — it can never be
// disabled so reviewers always have a way to discover command state.
const CONFIGURABLE_COMMANDS: { name: string; description: string }[] = [
{ name: "rebuild", description: "Rebuild the preview (reuse instance, or re-provision if stopped)." },
{ name: "stop", description: "Stop and terminate the preview instance." },
{ name: "start", description: "Start a stopped/ignored preview, or create one." },
{ name: "logs", description: "Post the last 50 lines of logs as a comment." },
{ name: "ignore", description: "Ignore this PR — skip future pushes and commands." },
];
function CommandToggles({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
const toggle = (name: string, enabled: boolean) => {
if (enabled) onChange(value.filter(c => c !== name));
else if (!value.includes(name)) onChange([...value, name]);
};
return (
<div>
<label className={labelCls}>PR Comment Commands</label>
<p className="text-xs text-gray-400 dark:text-slate-500 mb-2">
Control which <code>/pp</code> commands reviewers can run from PR comments. <code>/pp help</code> is always available.
</p>
<div className="space-y-1">
{CONFIGURABLE_COMMANDS.map(cmd => {
const enabled = !value.includes(cmd.name);
return (
<label key={cmd.name} className="flex items-start gap-2 py-1 cursor-pointer">
<input type="checkbox" checked={enabled}
onChange={e => toggle(cmd.name, e.target.checked)}
className="rounded mt-0.5" />
<span className="text-sm">
<code className="font-mono">/pp {cmd.name}</code>
<span className="text-gray-500 dark:text-slate-400"> {cmd.description}</span>
</span>
</label>
);
})}
</div>
</div>
);
}
function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
const entries = Object.entries(value);
const addEntry = () => onChange({ ...value, "": "" });
const updateEntry = (oldKey: string, newKey: string, newVal: string) => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (k === oldKey) next[newKey] = newVal;
else next[k] = v;
}
onChange(next);
};
const removeEntry = (k: string) => {
const next = { ...value };
delete next[k];
onChange(next);
};
return (
<div>
<label className={labelCls}>Environment Variables</label>
<div className="space-y-1">
{entries.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<input type="text" value={k} placeholder="KEY"
onChange={e => updateEntry(k, e.target.value, v)}
className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} />
<input type="password" value={v} placeholder="value"
onChange={e => updateEntry(k, k, e.target.value)}
className={`${inputCls} flex-1 min-w-0 font-mono text-xs`} />
<button onClick={() => removeEntry(k)} className="text-red-500 hover:text-red-700 px-2"><X size={16} /></button>
</div>
))}
<button onClick={addEntry} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<Plus size={14} /> Add variable
</button>
</div>
</div>
);
}
const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1";
const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500";
+155 -249
View File
@@ -1,48 +1,48 @@
import React, { useEffect, useState } from "react";
import { api } from "../services/api";
import { useNavigate } from "react-router-dom";
import { api, formatUsd } from "../services/api";
import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog";
const INSTANCE_TYPES = [
{ value: "t2.micro", label: "t2.micro", cost: "$0.012/hr" },
{ value: "t2.medium", label: "t2.medium", cost: "$0.046/hr" },
{ value: "t3.medium", label: "t3.medium", cost: "$0.042/hr" },
{ value: "t3.large", label: "t3.large", cost: "$0.083/hr" },
{ value: "m5.large", label: "m5.large", cost: "$0.096/hr" },
{ value: "c5.large", label: "c5.large", cost: "$0.085/hr" },
];
import { usePageTitle } from "../hooks/usePageTitle";
import { RefreshCw, Link2, Search, X, Lock, Settings } from "lucide-react";
interface Repo {
owner: string;
name: string;
fullName: string;
htmlUrl: string;
isPrivate: boolean;
isEnabled: boolean;
claimedByOther: boolean;
config: any;
costUsd: number;
}
type VisibilityFilter = "all" | "public" | "private";
type StatusFilter = "all" | "enabled" | "disabled";
export function Repos() {
const navigate = useNavigate();
usePageTitle("Repositories");
const [repos, setRepos] = useState<Repo[]>([]);
const [loading, setLoading] = useState(true);
const [expandedRepo, setExpandedRepo] = useState<string | null>(null);
const [configs, setConfigs] = useState<Record<string, any>>({});
const [disableConfirm, setDisableConfirm] = useState<{ owner: string; repo: string } | null>(null);
const [togglingRepo, setTogglingRepo] = useState<string | null>(null);
const [savingRepo, setSavingRepo] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [visibility, setVisibility] = useState<VisibilityFilter>("all");
const [status, setStatus] = useState<StatusFilter>("all");
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 250);
return () => clearTimeout(t);
}, [search]);
const load = async () => {
setLoading(true);
const res = await api.repos.list();
if (res.ok) {
const data = (res.data || []) as Repo[];
setRepos(data);
const initial: Record<string, any> = {};
data.forEach(r => {
const key = `${r.owner}/${r.name}`;
initial[key] = r.config || defaultConfig();
});
setConfigs(initial);
setRepos((res.data || []) as Repo[]);
} else {
toast.error(res.message || "Failed to load repos");
}
@@ -51,23 +51,6 @@ export function Repos() {
useEffect(() => { load(); }, []);
function defaultConfig() {
return {
instanceType: "t2.medium",
inactivityHours: 12,
port: 3000,
denyList: [],
envVars: {},
useDockerCompose: false,
composeFilePath: "docker-compose.yml",
aptPackages: [],
setupCommands: [],
buildCommands: [],
postBuildCommands: [],
runCommand: "",
};
}
const handleToggle = async (repo: Repo) => {
const key = `${repo.owner}/${repo.name}`;
if (repo.isEnabled) {
@@ -92,26 +75,26 @@ export function Repos() {
else toast.error(res.message || "Failed to disable repo");
};
const handleSaveConfig = async (owner: string, name: string) => {
const key = `${owner}/${name}`;
setSavingRepo(key);
const config = configs[key] || defaultConfig();
const res = await api.repos.saveConfig({ owner, repo: name, ...config });
setSavingRepo(null);
if (res.ok) toast.success("Config saved");
else toast.error(res.message || "Failed to save config");
};
const updateConfig = (key: string, field: string, value: any) => {
setConfigs(prev => ({ ...prev, [key]: { ...(prev[key] || defaultConfig()), [field]: value } }));
};
if (loading) {
return <div className="space-y-3">
{[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
</div>;
}
const query = debouncedSearch.trim().toLowerCase();
const filteredRepos = repos.filter(repo => {
if (query && !repo.fullName.toLowerCase().includes(query)) return false;
if (visibility === "public" && repo.isPrivate) return false;
if (visibility === "private" && !repo.isPrivate) return false;
if (status === "enabled" && !repo.isEnabled) return false;
if (status === "disabled" && repo.isEnabled) return false;
return true;
});
const groupedSections = [
{ label: "Configured", repos: groupReposByOwner(filteredRepos.filter(repo => repo.config)) },
{ label: "Other repos", repos: groupReposByOwner(filteredRepos.filter(repo => !repo.config)) },
].filter(section => section.repos.length > 0);
return (
<div className="max-w-3xl space-y-4">
<ConfirmDialog
@@ -126,221 +109,144 @@ export function Repos() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Repo Configuration</h1>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> Refresh</button>
<button onClick={load} className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"><RefreshCw size={14} /> Refresh</button>
</div>
{repos.length === 0 && (
<div className="text-center py-16">
<div className="text-5xl mb-4">🔗</div>
<Link2 size={48} className="mx-auto mb-4 text-gray-400 dark:text-slate-500" />
<h2 className="text-lg font-semibold mb-2">No repos found</h2>
<p className="text-gray-500 dark:text-slate-400">Configure your Gitea credentials in Settings first.</p>
</div>
)}
{repos.map(repo => {
const key = `${repo.owner}/${repo.name}`;
const expanded = expandedRepo === key;
const config = configs[key] || defaultConfig();
const isToggling = togglingRepo === key;
return (
<div key={key} className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{repo.fullName}</p>
{repo.claimedByOther && (
<span className="text-xs text-amber-600 dark:text-amber-400">🔒 Claimed by another user</span>
)}
</div>
<div className="flex items-center gap-2">
{!repo.claimedByOther && (
<button
onClick={() => handleToggle(repo)}
disabled={isToggling}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
repo.isEnabled ? "bg-blue-600" : "bg-gray-300 dark:bg-slate-600"
} disabled:opacity-50`}
title={repo.isEnabled ? "Disable previews" : "Enable previews"}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${repo.isEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
)}
{repo.isEnabled && !repo.claimedByOther && (
<button
onClick={() => setExpandedRepo(expanded ? null : key)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{expanded ? "▲ Hide" : "▼ Config"}
</button>
)}
</div>
</div>
{expanded && (
<div className="border-t border-gray-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>Instance Type</label>
<select value={config.instanceType} onChange={e => updateConfig(key, "instanceType", e.target.value)} className={inputCls}>
{INSTANCE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label} {t.cost}</option>)}
<option value="custom">Custom...</option>
</select>
{config.instanceType === "custom" && (
<input type="text" placeholder="Custom instance type" className={`${inputCls} mt-1`}
onChange={e => updateConfig(key, "instanceType", e.target.value)} />
)}
</div>
<div>
<label className={labelCls}>App Port (default: 3000)</label>
<input type="number" value={config.port} onChange={e => updateConfig(key, "port", Number(e.target.value))} className={inputCls} min={1} max={65535} />
</div>
</div>
<div>
<label className={labelCls}>Inactivity Kill Timer: {config.inactivityHours}h</label>
<input type="range" min={0.5} max={72} step={0.5} value={config.inactivityHours}
onChange={e => updateConfig(key, "inactivityHours", Number(e.target.value))}
className="w-full mt-1" />
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>0.5h</span><span>12h</span><span>72h</span>
</div>
</div>
<div>
<label className={labelCls}>Deny List (comma-separated usernames)</label>
<input type="text"
value={config.denyList.join(", ")}
onChange={e => updateConfig(key, "denyList", e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean))}
className={inputCls}
placeholder="dependabot, renovate-bot"
/>
</div>
<div>
<label className={labelCls}>Apt Packages (space-separated)</label>
<input type="text"
value={config.aptPackages.join(" ")}
onChange={e => updateConfig(key, "aptPackages", e.target.value.split(" ").filter(Boolean))}
className={inputCls}
placeholder="python3 ffmpeg"
/>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<input type="checkbox" id={`compose-${key}`} checked={config.useDockerCompose}
onChange={e => updateConfig(key, "useDockerCompose", e.target.checked)} className="rounded" />
<label htmlFor={`compose-${key}`} className="text-sm font-medium">Use Docker Compose</label>
</div>
{config.useDockerCompose ? (
<div>
<label className={labelCls}>Compose File Path</label>
<input type="text" value={config.composeFilePath || "docker-compose.yml"}
onChange={e => updateConfig(key, "composeFilePath", e.target.value)}
className={inputCls} placeholder="docker-compose.yml" />
</div>
) : (
<div className="space-y-3">
<CommandList label="Build Commands" value={config.buildCommands}
onChange={v => updateConfig(key, "buildCommands", v)} />
<CommandList label="Post-Build Commands (optional)" value={config.postBuildCommands}
onChange={v => updateConfig(key, "postBuildCommands", v)} />
<div>
<label className={labelCls}>Run Command</label>
<input type="text" value={config.runCommand || ""}
onChange={e => updateConfig(key, "runCommand", e.target.value)}
className={inputCls} placeholder="node dist/index.js" />
</div>
</div>
)}
</div>
<CommandList label="Setup Commands (run once on first provision)"
value={config.setupCommands}
onChange={v => updateConfig(key, "setupCommands", v)} />
<EnvVarsEditor value={config.envVars || {}}
onChange={v => updateConfig(key, "envVars", v)} />
<div className="flex justify-end">
<button
onClick={() => handleSaveConfig(repo.owner, repo.name)}
disabled={savingRepo === key}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{savingRepo === key ? "Saving..." : "Save Config"}
</button>
</div>
</div>
{repos.length > 0 && (
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search repos..."
className={`${inputCls} pl-9`}
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-200"
title="Clear search"
><X size={16} /></button>
)}
</div>
);
})}
</div>
);
}
<select value={visibility} onChange={e => setVisibility(e.target.value as VisibilityFilter)} className={`${inputCls} sm:w-40`}>
<option value="all">All visibility</option>
<option value="public">Public only</option>
<option value="private">Private only</option>
</select>
<select value={status} onChange={e => setStatus(e.target.value as StatusFilter)} className={`${inputCls} sm:w-40`}>
<option value="all">All status</option>
<option value="enabled">Enabled only</option>
<option value="disabled">Disabled only</option>
</select>
</div>
)}
function CommandList({ label, value, onChange }: { label: string; value: string[]; onChange: (v: string[]) => void }) {
return (
<div>
<label className={labelCls}>{label}</label>
<div className="space-y-1">
{value.map((cmd, i) => (
<div key={i} className="flex gap-2">
<input type="text" value={cmd}
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }}
className={inputCls} placeholder="npm run build" />
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"></button>
{repos.length > 0 && filteredRepos.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-slate-400">
No repos match your search or filters.
</div>
)}
{groupedSections.map(section => (
<section key={section.label} className="space-y-3">
<div className="flex items-center gap-2 pt-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">{section.label}</h2>
<div className="h-px flex-1 bg-gray-200 dark:bg-slate-700" />
</div>
))}
<button onClick={() => onChange([...value, ""])} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
+ Add command
</button>
</div>
{section.repos.map(group => (
<div key={`${section.label}-${group.owner}`} className="space-y-2">
<div className="flex items-center gap-2 px-1">
<h3 className="text-sm font-semibold text-gray-700 dark:text-slate-200">{group.owner}</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">{group.repos.length}</span>
</div>
<div className="space-y-2">
{group.repos.map(repo => {
const key = `${repo.owner}/${repo.name}`;
const isToggling = togglingRepo === key;
return (
<div key={key} className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">{repo.name}</p>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 ${
repo.isPrivate
? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
}`}>
{repo.isPrivate ? "Private" : "Public"}
</span>
</div>
{repo.claimedByOther && (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"><Lock size={12} /> Claimed by another user</span>
)}
{repo.isEnabled && !repo.claimedByOther && (
<span className="block text-xs text-gray-500 dark:text-slate-400 mt-0.5" title="Estimated total EC2 cost for this repo's previews">
Est. cost: {formatUsd(repo.costUsd)}
</span>
)}
</div>
<div className="flex items-center gap-2">
{repo.isEnabled && !repo.claimedByOther && (
<button
onClick={() => navigate(`/repos/${repo.owner}/${repo.name}`)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
>
<Settings size={14} /> Configure
</button>
)}
{!repo.claimedByOther && (
<button
onClick={() => handleToggle(repo)}
disabled={isToggling}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
repo.isEnabled ? "bg-blue-600" : "bg-gray-300 dark:bg-slate-600"
} disabled:opacity-50`}
title={repo.isEnabled ? "Disable previews" : "Enable previews"}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${repo.isEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</section>
))}
</div>
);
}
function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) {
const entries = Object.entries(value);
const addEntry = () => onChange({ ...value, "": "" });
const updateEntry = (oldKey: string, newKey: string, newVal: string) => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (k === oldKey) next[newKey] = newVal;
else next[k] = v;
}
onChange(next);
};
const removeEntry = (k: string) => {
const next = { ...value };
delete next[k];
onChange(next);
};
function groupReposByOwner(repos: Repo[]) {
const ownerMap = new Map<string, Repo[]>();
for (const repo of repos) {
ownerMap.set(repo.owner, [...(ownerMap.get(repo.owner) ?? []), repo]);
}
return (
<div>
<label className={labelCls}>Environment Variables</label>
<div className="space-y-1">
{entries.map(([k, v], i) => (
<div key={i} className="flex gap-2">
<input type="text" value={k} placeholder="KEY"
onChange={e => updateEntry(k, e.target.value, v)}
className={`${inputCls} w-1/3 font-mono text-xs`} />
<input type="password" value={v} placeholder="value"
onChange={e => updateEntry(k, k, e.target.value)}
className={`${inputCls} flex-1 font-mono text-xs`} />
<button onClick={() => removeEntry(k)} className="text-red-500 hover:text-red-700 px-2"></button>
</div>
))}
<button onClick={addEntry} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
+ Add variable
</button>
</div>
</div>
);
return [...ownerMap.entries()]
.sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
.map(([owner, ownerRepos]) => ({
owner,
repos: ownerRepos.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true })),
}));
}
const labelCls = "block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1";
const inputCls = "w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500";
+2
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog";
@@ -13,6 +14,7 @@ const AWS_REGIONS = [
export function Settings() {
const { user, refresh } = useAuth();
usePageTitle("Settings");
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
+19 -16
View File
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify";
import { motion, AnimatePresence } from "motion/react";
import { Rocket, PartyPopper, ArrowRight } from "lucide-react";
const STEPS = ["Welcome", "Gitea Connection", "AWS Setup", "Your Webhook", "Enable a Repo", "Done"];
@@ -27,6 +29,7 @@ export function SetupWizard() {
const [saving, setSaving] = useState(false);
const [webhookUrl, setWebhookUrl] = useState("");
const [webhookSecret, setWebhookSecret] = useState("");
usePageTitle(`Setup | ${STEPS[step]}`);
// Auto-load webhook info whenever we reach step 3 (index 3 = "Your Webhook")
useEffect(() => {
@@ -34,7 +37,7 @@ export function SetupWizard() {
if (webhookSecret) return;
api.user.getWebhookSecret().then(res => {
if (res.ok && res.data) {
setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`);
setWebhookUrl(res.data.webhookUrl);
setWebhookSecret(res.data.token);
}
}).catch(() => {});
@@ -65,7 +68,7 @@ export function SetupWizard() {
refresh();
const secretRes = await api.user.getWebhookSecret();
if (secretRes.ok && secretRes.data) {
setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`);
setWebhookUrl(secretRes.data.webhookUrl);
setWebhookSecret(secretRes.data.token);
}
next();
@@ -99,15 +102,15 @@ export function SetupWizard() {
>
{step === 0 && (
<div className="text-center space-y-4">
<div className="text-5xl">🚀</div>
<Rocket size={48} className="mx-auto text-blue-600 dark:text-blue-400" />
<h2 className="text-2xl font-bold">Welcome to PR Previews</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm">
PP automatically provisions EC2 instances, builds your code, and posts live preview URLs on every pull request.
</p>
<p className="text-sm text-gray-500 dark:text-slate-400">Let's get you set up in a few steps.</p>
<div className="flex gap-3 justify-center mt-4">
<button onClick={next} className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Get Started
<button onClick={next} className="inline-flex items-center gap-1 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Get Started <ArrowRight size={16} />
</button>
<button onClick={skip} className="px-4 py-2.5 text-sm text-gray-500 dark:text-slate-400 hover:underline">
Skip wizard
@@ -127,12 +130,12 @@ export function SetupWizard() {
<input type="password" value={giteaPAT} onChange={e => setGiteaPAT(e.target.value)}
className={inputCls} placeholder="Personal Access Token" />
<p className="text-xs text-gray-400 dark:text-slate-500">
Required PAT scopes: <code>repository</code>, <code>issue</code>, <code>admin:repo_hook</code>
Required PAT scopes (Read <strong>and Write</strong>): <code>repository</code>, <code>issue</code>, <code>admin:repo_hook</code>
</p>
<div className="flex gap-3">
<button onClick={handleGiteaNext} disabled={saving || !giteaUrl || !giteaUsername || !giteaPAT}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50">
{saving ? "Validating..." : "Connect & Next →"}
{saving ? "Validating..." : <span className="inline-flex items-center gap-1">Connect & Next <ArrowRight size={16} /></span>}
</button>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
@@ -170,7 +173,7 @@ export function SetupWizard() {
<div className="flex gap-3">
<button onClick={handleAwsNext} disabled={saving || !awsKeyId || !awsSecret}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors disabled:opacity-50">
{saving ? "Validating..." : "Connect & Next →"}
{saving ? "Validating..." : <span className="inline-flex items-center gap-1">Connect & Next <ArrowRight size={16} /></span>}
</button>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
@@ -186,7 +189,7 @@ export function SetupWizard() {
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500 block mb-1">Webhook URL (Target URL in Gitea)</label>
<input type="text" readOnly value={webhookUrl || `${window.location.origin}/webhook/${user?.id}`}
<input type="text" readOnly value={webhookUrl || "(loading...)"}
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div>
<div>
@@ -195,8 +198,8 @@ export function SetupWizard() {
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div>
</div>
<button onClick={next} className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Next
<button onClick={next} className="w-full inline-flex items-center justify-center gap-1 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Next <ArrowRight size={16} />
</button>
</div>
)}
@@ -209,8 +212,8 @@ export function SetupWizard() {
</p>
<div className="flex gap-3">
<a href="/repos"
className="flex-1 py-2.5 text-center bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Repos
className="flex-1 inline-flex items-center justify-center gap-1 py-2.5 text-center bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Repos <ArrowRight size={16} />
</a>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div>
@@ -219,13 +222,13 @@ export function SetupWizard() {
{step === 5 && (
<div className="text-center space-y-4">
<div className="text-5xl">🎉</div>
<PartyPopper size={48} className="mx-auto text-blue-600 dark:text-blue-400" />
<h2 className="text-2xl font-bold">You're all set!</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm">
Open a pull request on an enabled repo and PP will provision a preview automatically.
</p>
<button onClick={() => navigate("/")} className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Dashboard
<button onClick={() => navigate("/")} className="inline-flex items-center gap-1 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Go to Dashboard <ArrowRight size={16} />
</button>
</div>
)}
+25
View File
@@ -46,10 +46,14 @@ export const api = {
saveConfig: (data: any) => request("POST", "/repos/config", data),
toggle: (owner: string, repo: string, enabled: boolean) => request("POST", "/repos/toggle", { owner, repo, enabled }),
},
stats: {
get: () => request("GET", "/stats"),
},
previews: {
list: () => request("GET", "/previews"),
get: (id: number) => request("GET", `/previews/${id}`),
stop: (id: number) => request("POST", `/previews/${id}/stop`),
rebuild: (id: number) => request("POST", `/previews/${id}/rebuild`),
},
admin: {
listUsers: () => request("GET", "/admin/users"),
@@ -63,6 +67,14 @@ export const api = {
},
};
// Format an estimated USD cost. Sub-cent values keep more precision so a
// freshly-started preview doesn't just read "$0.00". See backend lib/cost.ts.
export function formatUsd(n: number | null | undefined): string {
const v = n ?? 0;
if (v > 0 && v < 0.01) return `$${v.toFixed(4)}`;
return `$${v.toFixed(2)}`;
}
export function openLogsWs(previewId: number, onMessage: (msg: any) => void, onClose?: () => void): WebSocket {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/api/previews/${previewId}/logs`);
@@ -72,3 +84,16 @@ export function openLogsWs(previewId: number, onMessage: (msg: any) => void, onC
ws.onclose = onClose || (() => {});
return ws;
}
// Live stream of the running app's own stdout/stderr (the app process, not the
// PP deploy engine). Backed by an on-demand SSH tail — only produces output
// while the preview is live.
export function openAppLogsWs(previewId: number, onMessage: (msg: any) => void, onClose?: () => void): WebSocket {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/api/previews/${previewId}/applogs`);
ws.onmessage = (e) => {
try { onMessage(JSON.parse(e.data)); } catch {}
};
ws.onclose = onClose || (() => {});
return ws;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+1
View File
@@ -1,6 +1,7 @@
{
"name": "pp",
"private": true,
"license": "Apache-2.0",
"packageManager": "pnpm@11.5.2+sha1.7ab39d363d1ca5b2fb97795f45291083da4a1393",
"devDependencies": {
"prisma": "^6.19.3"
+43 -30
View File
@@ -33,6 +33,37 @@ importers:
'@rjweb/utils':
specifier: ^1.12.29
version: 1.12.29
axios:
specifier: ^1.7.0
version: 1.18.1
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
dotenv:
specifier: ^17.0.0
version: 17.4.2
node-cron:
specifier: ^3.0.3
version: 3.0.3
pino:
specifier: ^10.0.0
version: 10.3.1
prisma:
specifier: ^6.0.0
version: 6.19.3(typescript@5.9.3)
rjweb-server:
specifier: ^9.8.6
version: 9.9.0(@types/node@22.20.1)
ssh2:
specifier: ^1.16.0
version: 1.17.0
ws:
specifier: ^8.18.0
version: 8.21.1(bufferutil@4.1.0)
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
@@ -45,51 +76,24 @@ importers:
'@types/ws':
specifier: ^8.5.0
version: 8.18.1
axios:
specifier: ^1.7.0
version: 1.18.1
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
dotenv:
specifier: ^17.0.0
version: 17.4.2
esbuild:
specifier: ^0.25.0
version: 0.25.12
node-cron:
specifier: ^3.0.3
version: 3.0.3
pino:
specifier: ^10.0.0
version: 10.3.1
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
prisma:
specifier: ^6.0.0
version: 6.19.3(typescript@5.9.3)
rimraf:
specifier: ^5.0.0
version: 5.0.10
rjweb-server:
specifier: ^9.8.6
version: 9.9.0(@types/node@22.20.1)
ssh2:
specifier: ^1.16.0
version: 1.17.0
typescript:
specifier: ^5.0.0
version: 5.9.3
ws:
specifier: ^8.18.0
version: 8.21.1(bufferutil@4.1.0)
zod:
specifier: ^3.24.0
version: 3.25.76
frontend:
dependencies:
lucide-react:
specifier: ^1.26.0
version: 1.26.0(react@19.2.8)
motion:
specifier: ^12.0.0
version: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -1375,6 +1379,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.26.0:
resolution: {integrity: sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -3186,6 +3195,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.26.0(react@19.2.8):
dependencies:
react: 19.2.8
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Preview" ADD COLUMN "accumulatedCostUsd" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "instanceLaunchedAt" TIMESTAMP(3),
ADD COLUMN "instanceType" VARCHAR(64);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RepoConfig" ADD COLUMN "disabledCommands" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Preview" ADD COLUMN "stopReason" VARCHAR(128);
@@ -0,0 +1,10 @@
ALTER TABLE "RepoConfig" ALTER COLUMN "instanceType" SET DEFAULT 't3.medium';
ALTER TABLE "AdminSettings" ALTER COLUMN "defaultInstanceType" SET DEFAULT 't3.medium';
UPDATE "RepoConfig"
SET "instanceType" = 't3.medium'
WHERE "instanceType" = 't2.medium';
UPDATE "AdminSettings"
SET "defaultInstanceType" = 't3.medium'
WHERE "defaultInstanceType" = 't2.medium';
@@ -0,0 +1,6 @@
ALTER TABLE "RepoConfig" ADD COLUMN "preinstallTools" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "RepoConfig" ADD COLUMN "nodeVersion" VARCHAR(64);
UPDATE "RepoConfig"
SET "preinstallTools" = ARRAY['docker', 'node']::TEXT[],
"nodeVersion" = 'lts/*';
+17 -2
View File
@@ -73,10 +73,15 @@ model RepoConfig {
giteaWebhookId String? @db.VarChar(128)
denyList String[]
instanceType String @default("t2.medium") @db.VarChar(64)
// Names of `/pp` commands the operator has switched off for this repo
// (e.g. ["stop", "ignore"]). `help` is always available. See routes/webhook.ts.
disabledCommands String[]
instanceType String @default("t3.medium") @db.VarChar(64)
inactivityHours Float @default(12)
port Int @default(3000)
envVars Json @default("{}")
preinstallTools String[] @default([])
nodeVersion String? @db.VarChar(64)
useDockerCompose Boolean @default(false)
composeFilePath String? @db.VarChar(512)
aptPackages String[]
@@ -103,7 +108,17 @@ model Preview {
instanceId String? @db.VarChar(64)
instanceIp String? @db.VarChar(64)
port Int @default(3000)
// Cost tracking. `instanceType` snapshots the type of the currently- (or
// last-) launched instance so pricing is accurate even if the RepoConfig
// changes later. `instanceLaunchedAt` marks when the live EC2 started billing
// (nulled on stop). `accumulatedCostUsd` holds the finalized cost of all
// previously-terminated instance sessions for this preview; the live session's
// cost is added on top at read time. See lib/cost.ts.
instanceType String? @db.VarChar(64)
instanceLaunchedAt DateTime?
accumulatedCostUsd Float @default(0)
status PreviewStatus @default(PROVISIONING)
stopReason String? @db.VarChar(128)
logs String @default("") @db.Text
pid Int?
sshPrivateKey String? @db.Text
@@ -152,7 +167,7 @@ model NoConfigComment {
model AdminSettings {
id Int @id @default(1)
defaultInstanceType String @default("t2.medium") @db.VarChar(64)
defaultInstanceType String @default("t3.medium") @db.VarChar(64)
maxConcurrentInstancesPerUser Int @default(5)
logSizeLimitBytes Int @default(1048576)
previewRetentionDays Int @default(30)