feat(v2): full spec implementation — root SSH, bootstrap sentinel, HMAC webhook, all parametric routes fixed #1

Merged
space merged 9 commits from v2/full-spec-implementation into main 2026-07-26 14:26:39 +02:00
80 changed files with 8303 additions and 783 deletions
+4
View File
@@ -0,0 +1,4 @@
# Copy to .env.docker and fill in real values before running docker compose
SESSION_SECRET=change_me_min_32_chars_random_hex
PP_BASE_URL=http://localhost:5000
ENCRYPTION_KEY=change_me_64_hex_chars_used_to_encrypt_gitea_and_aws_credentials
+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 }}
+1
View File
@@ -2,6 +2,7 @@ node_modules/
backend/dist/ backend/dist/
frontend/dist/ frontend/dist/
.env .env
.env.docker
*.env.local *.env.local
*.log *.log
.DS_Store .DS_Store
+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`.
+40 -16
View File
@@ -1,25 +1,49 @@
FROM node:24-alpine AS frontend-builder # Stage 1: Install all workspace dependencies
WORKDIR /app/frontend # Using workspace-level install so pnpm-workspace.yaml settings (onlyBuiltDependencies,
COPY frontend/package.json frontend/pnpm-lock.yaml* ./ # supply-chain policies, etc.) apply uniformly — the canonical pnpm monorepo pattern.
RUN npm install -g pnpm && pnpm install --frozen-lockfile FROM node:24-alpine AS deps
COPY frontend/ ./ WORKDIR /app
RUN pnpm build
FROM node:24-alpine AS backend-builder # Copy workspace manifest files first for layer caching
WORKDIR /app/backend COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json backend/pnpm-lock.yaml* ./ COPY frontend/package.json ./frontend/
RUN npm install -g pnpm && pnpm install --frozen-lockfile COPY backend/package.json ./backend/
COPY backend/ ./ COPY prisma/schema.prisma ./prisma/schema.prisma
COPY prisma/ ../prisma/
RUN pnpm run generate && pnpm run build
# corepack activates the exact pnpm version declared in package.json#packageManager.
# This is hash-verified (sha1) and prevents newer pnpm from applying different
# supply-chain policies (e.g. minimumReleaseAge) against a lockfile generated with
# the pinned version.
RUN corepack enable && corepack install && \
pnpm install --frozen-lockfile
# 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 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:prod
# Stage 5: Minimal production runner
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
RUN apk add --no-cache openssl RUN apk add --no-cache openssl
ENV NODE_PATH=/app/node_modules/.pnpm/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/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 --from=frontend-builder /app/frontend/dist ./frontend/dist
COPY prisma/ ./prisma/ COPY prisma/ ./prisma/
@@ -27,4 +51,4 @@ WORKDIR /app/backend
EXPOSE 5000 EXPOSE 5000
CMD sh -c "npx 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) # 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 ## 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 ```bash
cp example.env .env 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 ```bash
docker compose up -d 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:
``` - Add your Gitea instance URL and PAT.
./backend/ # Node.js backend (rjweb-server + Prisma) - Add your AWS credentials and region.
./frontend/ # React + Vite + Tailwind CSS - Enable a repo.
./prisma/schema.prisma # PostgreSQL schema - Configure how that repo should build and run.
./docker-compose.yml # PP itself (backend + db)
./Dockerfile # Multi-stage build
```
## Environment Variables ## Environment Variables
| Variable | Description | | Variable | Required | Description |
|---|---| |---|---:|---|
| `DATABASE_URL` | PostgreSQL connection string | | `DATABASE_URL` | Yes | PostgreSQL connection string. In Docker Compose this is set automatically for the bundled database. |
| `SESSION_SECRET` | Cookie signing secret (32+ random chars) | | `SESSION_SECRET` | Yes | Secret used for session cookies. Use a long random value. |
| `PP_BASE_URL` | Public URL of this PP instance (no trailing slash) | | `PP_BASE_URL` | Yes | Public URL of this PP instance, with no trailing slash. Used for webhooks, CORS, and PR comment links. |
| `ENCRYPTION_KEY` | AES-256 key — 64 hex chars (32 bytes). Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | | `ENCRYPTION_KEY` | Yes | Exactly 64 hex characters. Encrypts Gitea PATs, AWS credentials, and SSH private keys at rest. |
| `PORT` | Backend port (default: 5000) | | `PORT` | No | Backend port. Defaults to `5000`. |
| `LOG_LEVEL` | Logging level: trace/debug/info/warn/error (default: info) | | `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 | ## Gitea Setup
|---|---|
| `/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 |
## 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 ```json
{ {
"Version": "2012-10-17", "Version": "2012-10-17",
"Statement": [{ "Statement": [
"Effect": "Allow", {
"Action": [ "Effect": "Allow",
"ec2:RunInstances", "ec2:TerminateInstances", "ec2:DescribeInstances", "Action": [
"ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", "ec2:RunInstances",
"ec2:AuthorizeSecurityGroupIngress", "ec2:DescribeSecurityGroups", "ec2:TerminateInstances",
"ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:CreateTags", "ec2:DescribeInstances",
"sts:GetCallerIdentity" "ec2:CreateSecurityGroup",
], "ec2:DeleteSecurityGroup",
"Resource": "*" "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 ```bash
# Database (via Docker) docker compose -f <compose-file> up -d --build --force-recreate
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
``` ```
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.
+22 -29
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) - inactivityHours (float, default 12, range 0.572)
- port (int, default 3000 — the port the app listens on inside the EC2 instance) - port (int, default 3000 — the port the app listens on inside the EC2 instance)
- envVars (JSON key-value) - 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) - useDockerCompose (bool)
- composeFilePath (string, nullable — path within repo, default `docker-compose.yml`) - composeFilePath (string, nullable — path within repo, default `docker-compose.yml`)
- aptPackages (string[]) - aptPackages (string[])
@@ -90,7 +92,7 @@ Tracks which PRs have already received a "no previews configured" comment to avo
### AdminSettings ### AdminSettings
Single row global config (seeded on first run). Single row global config (seeded on first run).
- id - id
- defaultInstanceType (string, default `t2.medium`) - defaultInstanceType (string, default `t3.medium`)
- maxConcurrentInstancesPerUser (int, default 5) - maxConcurrentInstancesPerUser (int, default 5)
- logSizeLimitBytes (int, default 1048576 — 1MB) - logSizeLimitBytes (int, default 1048576 — 1MB)
- previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged) - previewRetentionDays (int, default 30 — STOPPED/FAILED records older than this are purged)
@@ -154,7 +156,7 @@ PP does **not** react to its own comments (check that the commenter's username !
"ec2:DeleteSecurityGroup", "ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress", "ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups", "ec2:DescribeSecurityGroups",
"ec2:ImportKeyPair", "ec2:CreateKeyPair",
"ec2:DeleteKeyPair", "ec2:DeleteKeyPair",
"ec2:CreateTags", "ec2:CreateTags",
"sts:GetCallerIdentity" "sts:GetCallerIdentity"
@@ -185,18 +187,9 @@ pp:previewId = <Preview.id>
```bash ```bash
#!/bin/bash #!/bin/bash
apt-get update -y apt-get update -y
apt-get install -y curl git unzip build-essential apt-get install -y ca-certificates curl git unzip
# 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/*
``` ```
- 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. - All instance tags as above.
4. Poll `DescribeInstances` until status = `running` and the instance has a public IP. 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). 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. 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. 2. **Provision EC2** — as described above.
3. SSH in. All subsequent commands run over SSH. 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. 4. **Clone repo:**
5. **Clone repo:**
- Normal PR: `git clone https://<giteaUsername>:<PAT>@<giteaInstanceUrl>/<owner>/<repo>.git /opt/app` - 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). - 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` - `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`. 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`.
7. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`. 6. **Write `.env`:** Write `/opt/app/.env` from `RepoConfig.envVars`.
8. **Setup commands** (if any): run each in order. Only runs on first provision. 7. **Setup commands** (if any): run each in order. Only runs on first provision.
9. Set Preview status to `BUILDING`. 8. Set Preview status to `BUILDING`.
10. **Build commands:** run each in order. 9. **Build commands:** run each in order.
11. **Post-build commands** (if any): run each in order. 10. **Post-build commands** (if any): run each in order.
12. **Start:** 11. **Start:**
- Docker Compose: `cd /opt/app && docker compose -f <composeFilePath> up -d --build --force-recreate` - 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`. - 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`. 12. 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`. 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) #### Subsequent push (synchronize / existing RUNNING or FAILED Preview)
1. SSH into existing instance using `Preview.instanceIp` and decrypted `Preview.sshPrivateKey`. 1. SSH into existing instance using `Preview.instanceIp` and decrypted `Preview.sshPrivateKey`.
2. **Stop current process:** 2. **Stop current process:**
- Non-compose: `kill <Preview.pid>` (SIGTERM, wait 5s, SIGKILL if still running). - 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:** 3. **Pull latest:**
```bash ```bash
cd /opt/app && git fetch origin pull/<prNumber>/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD 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`. 5. **Overwrite `.env`** — re-write from current `RepoConfig.envVars`.
6. Set Preview status to `BUILDING`. Append `\n--- Redeploy: <commitSha> ---\n` to `Preview.logs`. 6. Set Preview status to `BUILDING`. Append `\n--- Redeploy: <commitSha> ---\n` to `Preview.logs`.
7. **Build commands** — re-run each. 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. - 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: - Expanded per-repo config:
- **Deny list** — tag input of Gitea usernames to skip. - **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. - **Inactivity kill timer** — slider 0.5h to 72h with labelled stops.
- **App port** — number input (default 3000). - **App port** — number input (default 3000).
- **Environment variables** — key/value editor, values masked. Add/remove rows. - **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): - **Commands** (shown for both modes unless noted):
- Apt packages — tag input (compose + non-compose) - Apt packages — tag input (compose + non-compose)
- Setup commands — ordered list, add/remove/reorder (compose + non-compose, run once on first provision) - 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!
+15 -11
View File
@@ -3,9 +3,11 @@
"version": "1.0.0", "version": "1.0.0",
"description": "PR Previews backend", "description": "PR Previews backend",
"private": true, "private": true,
"packageManager": "pnpm@11.5.2+sha1.7ab39d363d1ca5b2fb97795f45291083da4a1393",
"scripts": { "scripts": {
"dev": "rimraf dist && esbuild \"src/**/*.ts\" --platform=node --sourcemap --ignore-annotations --format=cjs --target=es2022 --outdir=dist && cd dist && node index.js", "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": "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", "start": "cd dist && node index.js",
"prod": "pnpm build && pnpm start", "prod": "pnpm build && pnpm start",
"migrate": "prisma migrate dev --schema=../prisma/schema.prisma", "migrate": "prisma migrate dev --schema=../prisma/schema.prisma",
@@ -18,23 +20,25 @@
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",
"@rjweb/runtime-node": "^1.1.1", "@rjweb/runtime-node": "^1.1.1",
"@rjweb/utils": "^1.12.29", "@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/bcryptjs": "^3.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/ssh2": "^1.15.0", "@types/ssh2": "^1.15.0",
"@types/ws": "^8.5.0", "@types/ws": "^8.5.0",
"axios": "^1.7.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.0.0",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"node-cron": "^3.0.3",
"pino": "^10.0.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"prisma": "^6.0.0",
"rimraf": "^5.0.0", "rimraf": "^5.0.0",
"rjweb-server": "^9.8.6", "typescript": "^5.0.0"
"ssh2": "^1.16.0",
"typescript": "^5.0.0",
"ws": "^8.18.0",
"zod": "^3.24.0"
} }
} }
+23 -9
View File
@@ -14,12 +14,14 @@ import { startJobWorker } from "./workers/jobWorker";
import { startCronWorkers } from "./workers/cronWorker"; import { startCronWorkers } from "./workers/cronWorker";
import { getAdminSettings } from "./lib/adminSettings"; import { getAdminSettings } from "./lib/adminSettings";
import { runOrphanCleanup } from "./services/orphanCleanup"; import { runOrphanCleanup } from "./services/orphanCleanup";
import { runWebhookReconciliation } from "./services/webhookReconcile";
import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth"; import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth";
import { webhookHandler } from "./routes/webhook"; import { webhookHandler } from "./routes/webhook";
import { getUserSettings, updateUsername, updatePassword, updateGitea, updateAws, getWebhookSecret, regenerateWebhookSecret } from "./routes/api/user"; import { getUserSettings, updateUsername, updatePassword, updateGitea, updateAws, getWebhookSecret, regenerateWebhookSecret } from "./routes/api/user";
import { listRepos, saveRepoConfig, toggleRepoEnabled, getRepoConfig } from "./routes/api/repos"; 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"; import { listUsers, createUser, updateUser, deleteUser, getSettings, updateSettings, adminListPreviews, adminStopPreview } from "./routes/api/admin";
const uiBuildPath = join(__dirname, "../../frontend/dist"); const uiBuildPath = join(__dirname, "../../frontend/dist");
@@ -54,7 +56,7 @@ server.path("/", (path) => path
// Webhook // Webhook
server.path("/", (path) => path server.path("/", (path) => path
.http("POST", "/webhook/:userId", (http) => http.onRequest(webhookHandler)) .http("POST", "/webhook/{userId}", (http) => http.onRequest(webhookHandler))
); );
// User settings // User settings
@@ -73,31 +75,42 @@ server.path("/", (path) => path
.http("GET", "/api/repos", (http) => http.onRequest(listRepos)) .http("GET", "/api/repos", (http) => http.onRequest(listRepos))
.http("POST", "/api/repos/config", (http) => http.onRequest(saveRepoConfig)) .http("POST", "/api/repos/config", (http) => http.onRequest(saveRepoConfig))
.http("POST", "/api/repos/toggle", (http) => http.onRequest(toggleRepoEnabled)) .http("POST", "/api/repos/toggle", (http) => http.onRequest(toggleRepoEnabled))
.http("GET", "/api/repos/:owner/:repo/config", (http) => http.onRequest(getRepoConfig)) .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 // Previews
server.path("/", (path) => path server.path("/", (path) => path
.http("GET", "/api/previews", (http) => http.onRequest(listPreviews)) .http("GET", "/api/previews", (http) => http.onRequest(listPreviews))
.http("GET", "/api/previews/:id", (http) => http.onRequest(getPreview)) .http("GET", "/api/previews/{id}", (http) => http.onRequest(getPreview))
.http("POST", "/api/previews/:id/stop", (http) => http.onRequest(stopPreviewRoute)) .http("POST", "/api/previews/{id}/stop", (http) => http.onRequest(stopPreviewRoute))
.ws("/api/previews/:id/logs", (ws) => ws .http("POST", "/api/previews/{id}/rebuild", (http) => http.onRequest(rebuildPreviewRoute))
.ws("/api/previews/{id}/logs", (ws) => ws
.onOpen(previewLogsWs) .onOpen(previewLogsWs)
.onMessage(async () => {}) .onMessage(async () => {})
.onClose(async () => {}) .onClose(async () => {})
) )
.ws("/api/previews/{id}/applogs", (ws) => ws
.onOpen(previewAppLogsWs)
.onMessage(async () => {})
.onClose(async () => {})
)
); );
// Admin // Admin
server.path("/", (path) => path server.path("/", (path) => path
.http("GET", "/api/admin/users", (http) => http.onRequest(listUsers)) .http("GET", "/api/admin/users", (http) => http.onRequest(listUsers))
.http("POST", "/api/admin/users", (http) => http.onRequest(createUser)) .http("POST", "/api/admin/users", (http) => http.onRequest(createUser))
.http("PATCH", "/api/admin/users/:id", (http) => http.onRequest(updateUser)) .http("PATCH", "/api/admin/users/{id}", (http) => http.onRequest(updateUser))
.http("DELETE", "/api/admin/users/:id", (http) => http.onRequest(deleteUser)) .http("DELETE", "/api/admin/users/{id}", (http) => http.onRequest(deleteUser))
.http("GET", "/api/admin/settings", (http) => http.onRequest(getSettings)) .http("GET", "/api/admin/settings", (http) => http.onRequest(getSettings))
.http("PUT", "/api/admin/settings", (http) => http.onRequest(updateSettings)) .http("PUT", "/api/admin/settings", (http) => http.onRequest(updateSettings))
.http("GET", "/api/admin/previews", (http) => http.onRequest(adminListPreviews)) .http("GET", "/api/admin/previews", (http) => http.onRequest(adminListPreviews))
.http("POST", "/api/admin/previews/:id/stop", (http) => http.onRequest(adminStopPreview)) .http("POST", "/api/admin/previews/{id}/stop", (http) => http.onRequest(adminStopPreview))
); );
// Static UI — must come AFTER API routes // Static UI — must come AFTER API routes
@@ -127,6 +140,7 @@ server
startJobWorker(); startJobWorker();
startCronWorkers(); startCronWorkers();
runOrphanCleanup().catch(e => logger.warn(e, "Orphan cleanup error")); runOrphanCleanup().catch(e => logger.warn(e, "Orphan cleanup error"));
runWebhookReconciliation().catch(e => logger.warn(e, "Webhook reconciliation error"));
logger.info("All workers started"); logger.info("All workers started");
}) })
.catch((err) => logger.error(err, "Server failed to start")); .catch((err) => logger.error(err, "Server failed to start"));
+2 -1
View File
@@ -1,9 +1,10 @@
import { prisma } from "./db"; import { prisma } from "./db";
import { DEFAULT_INSTANCE_TYPE } from "./instanceTypes";
export async function getAdminSettings() { export async function getAdminSettings() {
let settings = await prisma.adminSettings.findUnique({ where: { id: 1 } }); let settings = await prisma.adminSettings.findUnique({ where: { id: 1 } });
if (!settings) { if (!settings) {
settings = await prisma.adminSettings.create({ data: { id: 1 } }); settings = await prisma.adminSettings.create({ data: { id: 1, defaultInstanceType: DEFAULT_INSTANCE_TYPE } });
} }
return settings; 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();
export const COOKIE_NAME_EXPORT = COOKIE_NAME; 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)];
}
+11 -1
View File
@@ -1,8 +1,11 @@
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { randomBytes } from "crypto";
import { prisma } from "../../lib/db"; import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response"; import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors"; import { ERROR_MESSAGES } from "../../lib/errors";
import { getAdminSettings } from "../../lib/adminSettings"; import { getAdminSettings } from "../../lib/adminSettings";
import { computePreviewCostUsd } from "../../lib/cost";
import { normalizeInstanceType } from "../../lib/instanceTypes";
function requireAdmin(ctr: any) { function requireAdmin(ctr: any) {
const auth = ctr.getAuth?.(); const auth = ctr.getAuth?.();
@@ -43,6 +46,10 @@ export async function createUser(ctr: any) {
select: { id: true, username: true, isAdmin: true, isFounder: true }, select: { id: true, username: true, isAdmin: true, isFounder: true },
}); });
// Auto-create webhook token for new users
const webhookSecret = randomBytes(32).toString("hex");
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
return makeResponse({ ctr, content: { code: 201, data: user } }); return makeResponse({ ctr, content: { code: 201, data: user } });
} }
@@ -107,7 +114,7 @@ export async function updateSettings(ctr: any) {
} = body || {}; } = body || {};
const data: any = {}; const data: any = {};
if (defaultInstanceType) data.defaultInstanceType = defaultInstanceType; if (defaultInstanceType !== undefined) data.defaultInstanceType = normalizeInstanceType(defaultInstanceType);
if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser); if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser);
if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes); if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes);
if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays); if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays);
@@ -142,6 +149,7 @@ export async function adminListPreviews(ctr: any) {
prNumber: p.prNumber, prNumber: p.prNumber,
prTitle: p.prTitle, prTitle: p.prTitle,
status: p.status, status: p.status,
stopReason: p.stopReason,
instanceIp: p.instanceIp, instanceIp: p.instanceIp,
port: p.port, port: p.port,
createdAt: p.createdAt, createdAt: p.createdAt,
@@ -149,6 +157,8 @@ export async function adminListPreviews(ctr: any) {
repoOwner: p.repoConfig.repoOwner, repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName, repoName: p.repoConfig.repoName,
user: (p.repoConfig as any).user, 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 { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors"; import { ERROR_MESSAGES } from "../../lib/errors";
import { subscribeToLogs } from "../../services/deploy"; 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) { function requireAuth(ctr: any) {
const auth = ctr.getAuth?.(); const auth = ctr.getAuth?.();
@@ -27,6 +31,7 @@ export async function listPreviews(ctr: any) {
prTitle: p.prTitle, prTitle: p.prTitle,
commitSha: p.commitSha, commitSha: p.commitSha,
status: p.status, status: p.status,
stopReason: p.stopReason,
instanceIp: p.instanceIp, instanceIp: p.instanceIp,
port: p.port, port: p.port,
createdAt: p.createdAt, createdAt: p.createdAt,
@@ -34,6 +39,8 @@ export async function listPreviews(ctr: any) {
lastActivityAt: p.lastActivityAt, lastActivityAt: p.lastActivityAt,
repoOwner: p.repoConfig.repoOwner, repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName, repoName: p.repoConfig.repoName,
instanceType: p.instanceType,
costUsd: computePreviewCostUsd(p),
})) }))
} }
}); });
@@ -62,6 +69,7 @@ export async function getPreview(ctr: any) {
prTitle: preview.prTitle, prTitle: preview.prTitle,
commitSha: preview.commitSha, commitSha: preview.commitSha,
status: preview.status, status: preview.status,
stopReason: preview.stopReason,
instanceIp: preview.instanceIp, instanceIp: preview.instanceIp,
port: preview.port, port: preview.port,
logs: preview.logs, logs: preview.logs,
@@ -71,6 +79,9 @@ export async function getPreview(ctr: any) {
lastActivityAt: preview.lastActivityAt, lastActivityAt: preview.lastActivityAt,
repoOwner: preview.repoConfig.repoOwner, repoOwner: preview.repoConfig.repoOwner,
repoName: preview.repoConfig.repoName, repoName: preview.repoConfig.repoName,
instanceType: preview.instanceType,
costUsd: computePreviewCostUsd(preview),
costRateUsd: preview.instanceLaunchedAt ? hourlyRateUsd(preview.instanceType) : 0,
jobs: preview.jobs.map(j => ({ jobs: preview.jobs.map(j => ({
id: j.id, id: j.id,
type: j.type, type: j.type,
@@ -104,8 +115,62 @@ export async function stopPreviewRoute(ctr: any) {
return makeResponse({ ctr, content: { code: 200, message: "Stop job enqueued" } }); 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) { export async function previewLogsWs(ctr: any) {
const auth = ctr.getAuth?.(); const auth = await resolveWsAuth(ctr);
if (!auth?.success) { if (!auth?.success) {
ctr.close(1008, "Unauthorized"); ctr.close(1008, "Unauthorized");
return; return;
@@ -121,13 +186,45 @@ export async function previewLogsWs(ctr: any) {
return; 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) => { const unsub = subscribeToLogs(id, (text) => {
try { try {
ctr.print(JSON.stringify({ type: "append", text })); ctr.print("text", { type: "append", text });
} catch {} } catch {}
}); });
ctr.$abort(unsub); 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 { fetchUserRepos, registerWebhook, deleteWebhook } from "../../services/gitea";
import { getAdminSettings } from "../../lib/adminSettings"; import { getAdminSettings } from "../../lib/adminSettings";
import { env } from "../../lib/env"; 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) { function requireAuth(ctr: any) {
const auth = ctr.getAuth?.(); 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 configs = await prisma.repoConfig.findMany({ where: { userId: user.id } });
const configMap = new Map(configs.map(c => [`${c.repoOwner}/${c.repoName}`, c])); 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 allOwners = [...new Set(giteaRepos.map((r: any) => r.full_name?.split("/")[0]).filter(Boolean))];
const allConfigs = await prisma.repoConfig.findMany({ const allConfigs = await prisma.repoConfig.findMany({
where: { repoOwner: { in: allOwners } }, where: { repoOwner: { in: allOwners } },
@@ -43,10 +56,20 @@ export async function listRepos(ctr: any) {
name, name,
fullName: r.full_name, fullName: r.full_name,
htmlUrl: r.html_url, htmlUrl: r.html_url,
isPrivate: Boolean(r.private),
isEnabled: config?.isEnabled ?? false, isEnabled: config?.isEnabled ?? false,
claimedByOther: claimedByOthers.has(key), claimedByOther: claimedByOthers.has(key),
config: config ? safeConfig : null, 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 } }); return makeResponse({ ctr, content: { code: 200, data: result } });
@@ -86,22 +109,29 @@ export async function saveRepoConfig(ctr: any) {
const settings = await getAdminSettings(); const settings = await getAdminSettings();
const useDockerCompose = Boolean(configData.useDockerCompose ?? false);
const preinstallTools = normalizePreinstallTools(configData.preinstallTools, useDockerCompose);
const sanitized = { const sanitized = {
repoOwner: owner, repoOwner: owner,
repoName: repo, repoName: repo,
userId: user.id, 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))), inactivityHours: Math.min(72, Math.max(0.5, Number(configData.inactivityHours ?? 12))),
port: Number(configData.port ?? 3000), port: Number(configData.port ?? 3000),
envVars: configData.envVars ?? {}, envVars: configData.envVars ?? {},
useDockerCompose: Boolean(configData.useDockerCompose ?? false), preinstallTools,
nodeVersion: normalizeNodeVersion(configData.nodeVersion, preinstallTools.includes("node")),
useDockerCompose,
composeFilePath: configData.composeFilePath ?? null, composeFilePath: configData.composeFilePath ?? null,
aptPackages: Array.isArray(configData.aptPackages) ? configData.aptPackages : [], aptPackages: normalizeAptPackages(configData.aptPackages),
setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [], setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [],
buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [], buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [],
postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [], postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [],
runCommand: configData.runCommand ?? null, runCommand: configData.runCommand ?? null,
denyList: Array.isArray(configData.denyList) ? configData.denyList : [], 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; 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 { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors"; import { ERROR_MESSAGES } from "../../lib/errors";
import { encrypt, decrypt } from "../../lib/encryption"; import { encrypt, decrypt } from "../../lib/encryption";
import { validateGiteaUrl } from "../../services/gitea"; import { updateWebhookSecret, validateGiteaUrl, validateGiteaToken } from "../../services/gitea";
import { validateAwsCredentials } from "../../services/ec2"; import { validateAwsCredentials } from "../../services/ec2";
import { env } from "../../lib/env"; import { env } from "../../lib/env";
@@ -14,6 +14,39 @@ function requireAuth(ctr: any) {
return auth.user; 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) { export async function getUserSettings(ctr: any) {
const user = requireAuth(ctr); const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); 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 }; 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 }); const updatedUser = 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 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) { export async function updateAws(ctr: any) {
const user = requireAuth(ctr); const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body(); 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) { if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
return makeResponse({ ctr, content: { code: 400, message: "AWS credentials and region required" } }); 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); const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } }); if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
let token = await prisma.webhookToken.findUnique({ where: { userId: user.id } }); const token = await getOrCreateWebhookToken(user.id);
if (!token) {
const secret = randomBytes(32).toString("hex");
token = await prisma.webhookToken.create({ data: { userId: user.id, token: secret } });
}
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) { 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 } } }); return makeResponse({ ctr, content: { code: 200, message: "Secret regenerated (no Gitea hooks to update)", data: { token: newSecret } } });
} }
const configs = await prisma.repoConfig.findMany({ const hookSync = await syncRegisteredRepoWebhooks(user.id, fullUser, newSecret);
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}`);
}
}
return makeResponse({ return makeResponse({
ctr, content: { ctr, content: {
code: 200, code: 200,
message: errors.length ? `Secret regenerated with ${errors.length} hook update errors` : "Secret regenerated and all hooks updated", message: hookSync.errors.length ? `Secret regenerated with ${hookSync.errors.length} hook update errors` : "Secret regenerated and all hooks updated",
data: { token: newSecret, errors }, data: { token: newSecret, errors: hookSync.errors, hookSync },
} }
}); });
} }
+6 -2
View File
@@ -42,7 +42,7 @@ export async function loginHandler(ctr: any) {
httpOnly: true, httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/", path: "/",
sameSite: "Lax", sameSite: "lax",
}), }),
); );
@@ -116,6 +116,10 @@ export async function firstUserHandler(ctr: any) {
data: { username, passwordHash: hash, isAdmin: true, isFounder: true }, data: { username, passwordHash: hash, isAdmin: true, isFounder: true },
}); });
// Auto-create a webhook token so it's immediately available in the setup wizard
const webhookSecret = randomBytes(32).toString("hex");
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
const sessionHash = randomBytes(32).toString("hex"); const sessionHash = randomBytes(32).toString("hex");
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } }); await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
@@ -125,7 +129,7 @@ export async function firstUserHandler(ctr: any) {
httpOnly: true, httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/", path: "/",
sameSite: "Lax", sameSite: "lax",
}), }),
); );
+104 -23
View File
@@ -86,15 +86,18 @@ async function handlePullRequestEvent(user: any, payload: any) {
}); });
if (!repoConfig) { if (!repoConfig) {
const existing = await prisma.noConfigComment.findUnique({ // Only post "no config" comment on new PR opens (not on sync/close events)
where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } }, if (action === "opened" || action === "reopened") {
}); const existing = await prisma.noConfigComment.findUnique({
if (!existing) { where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },
const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`; });
try { if (!existing) {
await postComment(user, owner, repoName, prNumber, body); const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`;
await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } }); try {
} catch {} await postComment(user, owner, repoName, prNumber, body);
await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });
} catch {}
}
} }
return; return;
} }
@@ -130,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({ const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber }, where: { repoConfigId: repoConfig.id, prNumber },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
@@ -183,7 +188,12 @@ async function handleIssueCommentEvent(user: any, payload: any) {
const repoName = repo.name; const repoName = repo.name;
const prNumber = issue.number; 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({ const repoConfig = await prisma.repoConfig.findFirst({
where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true }, where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },
@@ -204,8 +214,9 @@ async function handleIssueCommentEvent(user: any, payload: any) {
data: { lastActivityAt: new Date() }, 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 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({ const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber }, where: { repoConfigId: repoConfig.id, prNumber },
@@ -214,21 +225,45 @@ async function handleIssueCommentEvent(user: any, payload: any) {
const cloneUrl = repo.clone_url; const cloneUrl = repo.clone_url;
if (command === "rebuild") { // Operator-configured per-repo command switches. `help` can never be disabled
if (!preview || (preview.status !== "RUNNING" && preview.status !== "FAILED")) return; // 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({ await prisma.job.create({
data: { data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false } },
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") { } 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({ await prisma.job.create({
data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } }, data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } },
}); });
await ack(user, owner, repoName, prNumber, "🛑 Stopping preview...");
} else if (command === "start") { } else if (command === "start") {
if (!preview) { if (!preview) {
const newPreview = await prisma.preview.create({ const newPreview = await prisma.preview.create({
@@ -237,14 +272,21 @@ async function handleIssueCommentEvent(user: any, payload: any) {
await prisma.job.create({ await prisma.job.create({
data: { previewId: newPreview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: "", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } }, 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") { } 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({ await prisma.job.create({
data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } }, 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") { } 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 lastLines = (preview.logs || "").split("\n").slice(-50).join("\n");
const logBody = `**PP Logs** (last 50 lines)\n\`\`\`\n${lastLines}\n\`\`\``; const logBody = `**PP Logs** (last 50 lines)\n\`\`\`\n${lastLines}\n\`\`\``;
await postComment(user, owner, repoName, prNumber, logBody); await postComment(user, owner, repoName, prNumber, logBody);
@@ -256,9 +298,48 @@ async function handleIssueCommentEvent(user: any, payload: any) {
} else { } else {
await prisma.preview.update({ where: { id: preview.id }, data: { status: "IGNORED" } }); 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> { async function isRepoAdmin(user: any, owner: string, repo: string, username: string): Promise<boolean> {
try { try {
const { getRepoCollaboratorPermission } = await import("../services/gitea"); 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);
};
}
+396 -91
View File
@@ -10,10 +10,13 @@ import {
launchInstance, launchInstance,
waitForInstanceRunning, waitForInstanceRunning,
terminateInstance, terminateInstance,
waitForInstanceTerminated,
deleteKeyPairAws, deleteKeyPairAws,
deleteSecurityGroupAws, deleteSecurityGroupAws,
} from "./ec2"; } 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 { import {
buildPrCommentBody, buildPrCommentBody,
postComment, postComment,
@@ -23,6 +26,15 @@ import type { Preview, RepoConfig, User } from "@prisma/client";
const log = createLogger("DEPLOY"); 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>(); const activeSshSessions = new Map<number, SshSession>();
export function abortJobForPreview(previewId: number) { 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) { export async function appendLog(previewId: number, text: string) {
const settings = await getAdminSettings(); const settings = await getAdminSettings();
const preview = await prisma.preview.findUnique({ where: { id: previewId } }); const preview = await prisma.preview.findUnique({ where: { id: previewId } });
if (!preview) return; if (!preview) return;
let logs = (preview.logs || "") + text; const stamped = stampLines(text);
let logs = (preview.logs || "") + stamped;
if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) { if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const marker = "--- logs truncated ---\n"; const marker = "--- logs truncated ---\n";
while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) { 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 } }); await prisma.preview.update({ where: { id: previewId }, data: { logs } });
broadcastLogUpdate(previewId, text); broadcastLogUpdate(previewId, stamped);
} }
const logSubscribers = new Map<number, Set<(text: string) => void>>(); const logSubscribers = new Map<number, Set<(text: string) => void>>();
@@ -70,29 +93,6 @@ async function updateStatus(previewId: number, status: Preview["status"], extra:
await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } }); await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
} }
async function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) {
const body = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: statusLine,
commitSha: preview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
lastLogLines,
instanceIp: preview.instanceIp ?? undefined,
port: preview.port,
});
if (preview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);
} catch (e) {
log.warn({ e }, "Failed to update Gitea comment");
}
}
}
export async function runDeploy(jobId: number) { export async function runDeploy(jobId: number) {
const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } }); const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
if (!job || !job.preview) { if (!job || !job.preview) {
@@ -100,6 +100,10 @@ export async function runDeploy(jobId: number) {
return; return;
} }
// Clear any stale abort signal left over from aborting the previous job for this preview.
// The new job should not be cancelled by a signal meant for the old one.
if (job.preview.id) abortSignals.delete(job.preview.id);
await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } }); await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } });
const preview = job.preview as any; const preview = job.preview as any;
@@ -113,7 +117,14 @@ export async function runDeploy(jobId: number) {
if (isFirstDeploy) { if (isFirstDeploy) {
await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl); await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);
} else { } 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() } }); await prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: new Date() } });
@@ -187,14 +198,18 @@ async function firstDeploy(
throw new Error("Concurrent instance limit reached"); 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({ const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: "🟡 Provisioning EC2 instance...", status: "🟡 Provisioning EC2 instance...",
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL, commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
}); });
let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody); const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } }); await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
checkAbort(previewId); checkAbort(previewId);
@@ -226,9 +241,28 @@ 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),
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`); // 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); checkAbort(previewId);
@@ -243,31 +277,78 @@ async function firstDeploy(
}) })
); );
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\n`); await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH (as ubuntu)...\n`);
checkAbort(previewId); checkAbort(previewId);
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); activeSshSessions.set(previewId, sshSession);
try { try {
if (repoConfig.aptPackages.length > 0) { await appendLog(previewId, `[PP] Bootstrap complete. Starting setup...\n`);
await runSshStep(previewId, sshSession, `sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
}
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : ""; const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
const parsedUrl = new URL(cloneUrl.startsWith("http") ? cloneUrl : `https://${cloneUrl}`); const parsedUrl = new URL(cloneUrl.startsWith("http") ? cloneUrl : `https://${cloneUrl}`);
parsedUrl.username = encodeURIComponent(user.giteaUsername || ""); parsedUrl.username = "";
parsedUrl.password = encodeURIComponent(giteaPat); parsedUrl.password = "";
const authCloneUrl = parsedUrl.toString(); const cleanCloneUrl = parsedUrl.toString();
await runSshStep(previewId, sshSession, `git clone '${authCloneUrl}' /opt/app`); const authHeader = Buffer.from(`${user.giteaUsername || ""}:${giteaPat}`).toString("base64");
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true); // 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`);
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() }); // 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.
await appendLog(previewId, `$ git clone '${cleanCloneUrl}' /opt/app\n`);
const cloneCommand = giteaPat
? `git -c http.extraHeader='Authorization: Basic ${authHeader}' clone '${cleanCloneUrl}' /opt/app`
: `git clone '${cleanCloneUrl}' /opt/app`;
const cloneResult = await sshSession.exec(cloneCommand);
if (cloneResult.stdout) await appendLog(previewId, cloneResult.stdout);
if (cloneResult.stderr) {
// Mask PAT in stderr output too
const maskedStderr = cloneResult.stderr.replace(giteaPat, "****").replace(authHeader, "****");
await appendLog(previewId, maskedStderr);
}
if (cloneResult.code !== 0) throw new Error(`git clone failed with exit code ${cloneResult.code}`);
// 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(), stopReason: null, stoppedAt: null });
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId, await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
buildPrCommentBody({ buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
@@ -291,16 +372,20 @@ async function redeploy(
prTitle: string, prTitle: string,
) { ) {
const previewId = preview.id; const previewId = preview.id;
const instanceIp = preview.instanceIp!;
const privateKey = decrypt(preview.sshPrivateKey!);
const sshSession = await connectSsh(instanceIp, privateKey, 30_000); // Always use fresh data from DB for connection details
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
if (!freshPreview?.instanceIp || !freshPreview?.sshPrivateKey) {
throw new Error("No active instance found for redeploy — cannot SSH in");
}
const instanceIp = freshPreview.instanceIp;
const privateKey = decrypt(freshPreview.sshPrivateKey);
const sshSession = await connectSsh(instanceIp, privateKey, 60_000);
activeSshSessions.set(previewId, sshSession); activeSshSessions.set(previewId, sshSession);
await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`); await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`);
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
const commentBody = buildPrCommentBody({ const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber, owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟡 Building... (EC2 at ${instanceIp})`, status: `🟡 Building... (EC2 at ${instanceIp})`,
@@ -312,17 +397,45 @@ async function redeploy(
try { try {
if (repoConfig.useDockerCompose) { if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml"; const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`); await runSshStep(previewId, sshSession, `cd /opt/app && sudo docker compose -f ${shellQuote(composePath)} down 2>&1 || true`);
} else if (freshPreview?.pid) { } else {
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`); // 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 updateStatus(previewId, "BUILDING");
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false); 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, await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, newCommentId,
buildPrCommentBody({ buildPrCommentBody({
@@ -337,36 +450,45 @@ async function redeploy(
} }
} }
const NVM_PREFIX = `export NVM_DIR="/root/.nvm"; source "$NVM_DIR/nvm.sh" 2>/dev/null;`; const NVM_PREFIX = `export NVM_DIR="$HOME/.nvm"; source "$NVM_DIR/nvm.sh" 2>/dev/null;`;
function withNvm(cmd: string): string { function shellQuote(value: string): string {
return `bash -c '${NVM_PREFIX} ${cmd.replace(/'/g, `'"'"'`)}'`; 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( async function setupAndBuild(
previewId: number, previewId: number,
sshSession: SshSession, sshSession: SshSession,
repoConfig: RepoConfig, repoConfig: RepoConfig,
preview: Preview,
commitSha: string, commitSha: string,
isFirstProvision: boolean, isFirstProvision: boolean,
) { ) {
// Detect and use Node version await ensurePreinstall(previewId, sshSession, repoConfig);
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);
// Write .env file // Write .env file safely using base64 to handle special chars and newlines
const envVars = repoConfig.envVars as Record<string, string>; const envVars = repoConfig.envVars as Record<string, string>;
const envLines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\\n"); const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
if (envLines) { const envB64 = Buffer.from(envContent).toString("base64");
await runSshStep(previewId, sshSession, `printf '${envLines}\\n' > /opt/app/.env`, false); await appendLog(previewId, `$ echo '<base64 .env> | base64 -d > /opt/app/.env'\n`);
} else { const envResult = await sshSession.exec(`echo '${envB64}' | base64 -d > /opt/app/.env`);
await runSshStep(previewId, sshSession, `touch /opt/app/.env`, false); if (envResult.code !== 0) throw new Error(".env write failed");
}
if (isFirstProvision) { if (isFirstProvision) {
for (const cmd of repoConfig.setupCommands) { for (const cmd of repoConfig.setupCommands) {
await runSshStep(previewId, sshSession, withNvm(`cd /opt/app && ${cmd}`)); await runSshStep(previewId, sshSession, withRuntime(repoConfig, cmd));
} }
} }
@@ -374,26 +496,146 @@ async function setupAndBuild(
if (repoConfig.useDockerCompose) { if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml"; 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 { } 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) { 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) { 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) { // Non-compose mode has no start step other than `runCommand`. If it's blank
const startCmd = withNvm(`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`); // there is nothing to launch — fail loudly instead of marking the preview
const res = await runSshStep(previewId, sshSession, startCmd); // RUNNING with no process behind it (a misleading green preview).
const pid = parseInt(res.stdout.trim(), 10); const runCommand = (repoConfig.runCommand || "").trim();
if (!isNaN(pid)) { if (!runCommand) {
await prisma.preview.update({ where: { id: previewId }, data: { pid } }); 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) { async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) {
checkAbortSession(sshSession); checkAbortSession(sshSession);
await appendLog(previewId, `$ ${command}\n`); await appendLog(previewId, `$ ${command}\n`);
@@ -406,6 +648,37 @@ async function runSshStep(previewId: number, sshSession: SshSession, command: st
return res; 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) { function checkAbortSession(session: SshSession) {
if (session.aborted) throw new Error("ABORTED"); if (session.aborted) throw new Error("ABORTED");
} }
@@ -424,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({ const preview = await prisma.preview.findUnique({
where: { id: previewId }, where: { id: previewId },
include: { repoConfig: { include: { user: true } } }, include: { repoConfig: { include: { user: true } } },
@@ -436,25 +709,57 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED
if (preview.instanceId) { if (preview.instanceId) {
const ec2 = makeEc2Client(user); const ec2 = makeEc2Client(user);
const instanceId = preview.instanceId;
// Terminate instance first, then clean up key pair and security group
try {
await terminateInstance(ec2, instanceId);
} catch (e) {
log.warn({ e, instanceId }, "Failed to terminate instance");
}
// Delete key pair immediately (doesn't depend on instance state)
try { try {
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`); await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
} catch {} } catch {}
try { // The security group can't be deleted until the instance's ENI is released,
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`); // which only happens once the instance is fully terminated. Wait for that,
} catch {} // then delete (deleteSecurityGroupAws also retries on DependencyViolation).
try { const cleanupSecurityGroup = async () => {
await terminateInstance(ec2, preview.instanceId); try {
} catch {} await waitForInstanceTerminated(ec2, instanceId);
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
} catch (e) {
log.warn({ e, previewId }, "Failed to delete security group after termination");
}
};
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({ await prisma.preview.update({
where: { id: previewId }, where: { id: previewId },
data: { data: {
status: reason, status,
stoppedAt: new Date(), stopReason,
stoppedAt,
sshPrivateKey: null, sshPrivateKey: null,
sshKeyName: null, sshKeyName: null,
instanceId: null, instanceId: null,
instanceLaunchedAt: null,
accumulatedCostUsd: finalCostUsd,
}, },
}); });
@@ -462,7 +767,7 @@ export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED
owner: repoConfig.repoOwner, owner: repoConfig.repoOwner,
repo: repoConfig.repoName, repo: repoConfig.repoName,
prNumber: preview.prNumber, prNumber: preview.prNumber,
status: "⚫ Stopped (inactivity timeout / PR closed / manual stop)", status: `⚫ Stopped (${stopReason})`,
commitSha: preview.commitSha, commitSha: preview.commitSha,
updatedAt: new Date(), updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL, ppBaseUrl: env.PP_BASE_URL,
+85 -20
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 }> { export async function validateAwsCredentials(user: User): Promise<{ success: boolean; arn?: string; error?: string }> {
try { 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 sts = makeStsClient(user);
const res = await sts.send(new GetCallerIdentityCommand({})); const res = await sts.send(new GetCallerIdentityCommand({}));
return { success: true, arn: res.Arn }; return { success: true, arn: res.Arn };
@@ -115,18 +124,29 @@ export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: stri
} }
const BOOTSTRAP_SCRIPT = `#!/bin/bash const BOOTSTRAP_SCRIPT = `#!/bin/bash
set -e 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 update -y
apt-get install -y curl git unzip build-essential apt_wait
curl -fsSL https://get.docker.com | sh apt-get install -y ca-certificates curl git unzip
systemctl enable docker
systemctl start docker touch /var/lib/pp-bootstrap-done
apt-get install -y docker-compose-plugin
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/*
`; `;
export async function launchInstance(opts: { export async function launchInstance(opts: {
@@ -160,12 +180,17 @@ export async function launchInstance(opts: {
export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> { export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < maxWaitMs) { while (Date.now() - start < maxWaitMs) {
const res = await ec2.send(new DescribeInstancesCommand({ try {
InstanceIds: [instanceId], const res = await ec2.send(new DescribeInstancesCommand({
})); InstanceIds: [instanceId],
const inst = res.Reservations?.[0]?.Instances?.[0]; }));
if (inst?.State?.Name === "running" && inst.PublicIpAddress) { const inst = res.Reservations?.[0]?.Instances?.[0];
return inst.PublicIpAddress; if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
return inst.PublicIpAddress;
}
} catch (e: any) {
// AWS eventual consistency: instance may not be visible immediately after RunInstances
if (e.name !== "InvalidInstanceID.NotFound") throw e;
} }
await sleep(5000); await sleep(5000);
} }
@@ -176,6 +201,31 @@ export async function terminateInstance(ec2: EC2Client, instanceId: string): Pro
await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] })); 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> { export async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise<void> {
try { try {
await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName })); await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName }));
@@ -190,9 +240,24 @@ export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string):
Filters: [{ Name: "group-name", Values: [groupName] }], Filters: [{ Name: "group-name", Values: [groupName] }],
})); }));
const groupId = describe.SecurityGroups?.[0]?.GroupId; const groupId = describe.SecurityGroups?.[0]?.GroupId;
if (groupId) { if (!groupId) return;
await sleep(5000);
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId })); // 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) { } catch (e) {
log.warn({ e, groupName }, "Failed to delete security group"); 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[]> { export async function fetchUserRepos(user: User): Promise<any[]> {
const api = giteaApi(user); const api = giteaApi(user);
const repos: any[] = []; const repos: any[] = [];
let page = 1; let page = 1;
while (true) { while (true) {
const res = await api.get(`/repos/search?limit=50&page=${page}`); const res = await api.get(`/user/repos?limit=50&page=${page}`);
const data = res.data?.data ?? []; const data = Array.isArray(res.data) ? res.data : [];
if (data.length === 0) break; if (data.length === 0) break;
repos.push(...data); repos.push(...data);
if (data.length < 50) break; if (data.length < 50) break;
@@ -38,16 +86,34 @@ export async function fetchUserRepos(user: User): Promise<any[]> {
return repos; 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> { export async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise<number> {
const api = giteaApi(user); const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/hooks`, { const res = await api.post(`/repos/${owner}/${repo}/hooks`, {
type: "gitea", type: "gitea",
name: PP_WEBHOOK_NAME,
config: { config: {
url: webhookUrl, url: webhookUrl,
secret, secret,
content_type: "json", content_type: "json",
}, },
events: ["pull_request", "issue_comment"], events: PP_WEBHOOK_EVENTS,
active: true, active: true,
}); });
return res.data.id; 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> { export async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise<void> {
const api = giteaApi(user); 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}`, { await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {
name: PP_WEBHOOK_NAME,
config: { config: {
url: webhookUrl, url: webhookUrl,
secret: newSecret, secret: newSecret,
content_type: "json", content_type: "json",
}, },
events: ["pull_request", "issue_comment"], events: [...new Set([...currentEvents, ...PP_WEBHOOK_EVENTS])],
active: true, 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> { export async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise<number> {
const api = giteaApi(user); const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body }); try {
return res.data.id; 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> { export async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise<void> {
const api = giteaApi(user); 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> { export async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise<boolean> {
+74 -2
View File
@@ -5,6 +5,11 @@ const log = createLogger("SSH");
export interface SshSession { export interface SshSession {
exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>; 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; close(): void;
aborted: boolean; aborted: boolean;
abort(): void; abort(): void;
@@ -28,6 +33,26 @@ export async function connectSsh(host: string, privateKey: string, maxWaitMs = 3
if (aborted) throw new Error("SSH session aborted"); if (aborted) throw new Error("SSH session aborted");
return execOnConn(conn, command); 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() { close() {
try { conn.end(); } catch {} try { conn.end(); } catch {}
}, },
@@ -63,28 +88,75 @@ function tryConnect(host: string, privateKey: string, timeoutMs: number): Promis
username: "ubuntu", username: "ubuntu",
privateKey, privateKey,
readyTimeout: timeoutMs, readyTimeout: timeoutMs,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: { algorithms: {
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"], serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-ed25519"],
}, },
}); });
}); });
} }
const EXEC_TIMEOUT_MS = 30_000;
function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> { function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`SSH exec timed out: ${command.slice(0, 60)}`)), EXEC_TIMEOUT_MS);
conn.exec(command, (err, stream) => { conn.exec(command, (err, stream) => {
if (err) return reject(err); if (err) {
clearTimeout(timer);
return reject(err);
}
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
stream.on("data", (d: Buffer) => { stdout += d.toString(); }); stream.on("data", (d: Buffer) => { stdout += d.toString(); });
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
stream.on("close", (code: number) => { stream.on("close", (code: number) => {
clearTimeout(timer);
resolve({ stdout, stderr, code: code ?? 0 }); resolve({ stdout, stderr, code: code ?? 0 });
}); });
}); });
}); });
} }
// 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(
"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);
}
throw new Error("EC2 bootstrap did not complete within timeout");
}
function sleep(ms: number) { function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms)); return new Promise(r => setTimeout(r, ms));
} }
+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");
}
+9
View File
@@ -0,0 +1,9 @@
declare module 'node-cron' {
interface ScheduledTask {
start(): void;
stop(): void;
destroy(): void;
}
function schedule(cronExpression: string, func: () => void, options?: { scheduled?: boolean; timezone?: string }): ScheduledTask;
export { schedule, ScheduledTask };
}
+23 -16
View File
@@ -6,8 +6,8 @@ import { getAdminSettings } from "../lib/adminSettings";
const log = createLogger("CRON"); const log = createLogger("CRON");
export function startCronWorkers() { export function startCronWorkers() {
// Inactivity check every 30 minutes // Inactivity check every 5 minutes
cron.schedule("*/30 * * * *", async () => { cron.schedule("*/5 * * * *", async () => {
try { try {
await checkInactivity(); await checkInactivity();
} catch (e) { } catch (e) {
@@ -24,33 +24,40 @@ 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"); log.info("Cron workers started");
} }
async function checkInactivity() { async function checkInactivity() {
const settings = await getAdminSettings();
const now = new Date(); const now = new Date();
const running = await prisma.preview.findMany({ const running = await prisma.preview.findMany({
where: { status: "RUNNING" }, where: { status: "RUNNING" },
include: { repoConfig: { select: { inactivityHours: true } } },
}); });
for (const preview of running) { for (const preview of running) {
const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig const inactivityHours = preview.repoConfig.inactivityHours;
const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } }); const deadline = new Date(preview.lastActivityAt.getTime() + inactivityHours * 3600 * 1000);
if (!repoConfig) continue;
const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000);
if (now >= deadline) { if (now >= deadline) {
log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP"); log.info({ previewId: preview.id, inactivityHours }, "Preview inactive, enqueuing INACTIVITY_STOP");
await prisma.job.create({ // Only create one pending INACTIVITY_STOP per preview
data: { const existingStop = await prisma.job.findFirst({
previewId: preview.id, where: { previewId: preview.id, type: "INACTIVITY_STOP", status: { in: ["PENDING", "RUNNING"] } },
type: "INACTIVITY_STOP",
status: "PENDING",
payload: {},
},
}); });
if (!existingStop) {
await prisma.job.create({
data: {
previewId: preview.id,
type: "INACTIVITY_STOP",
status: "PENDING",
payload: {},
},
});
}
} }
} }
} }
+7 -1
View File
@@ -76,8 +76,14 @@ async function processJob(job: { id: number; type: string; previewId: number | n
await runDeploy(job.id); await runDeploy(job.id);
} else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") { } else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") {
if (job.previewId) { 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 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() } }); await prisma.job.update({ where: { id: job.id }, data: { status: "DONE", finishedAt: new Date() } });
} }
} }
+1 -3
View File
@@ -1,5 +1,3 @@
version: "3.9"
services: services:
pp-db: pp-db:
image: postgres:18-alpine image: postgres:18-alpine
@@ -9,7 +7,7 @@ services:
POSTGRES_PASSWORD: pp_password POSTGRES_PASSWORD: pp_password
POSTGRES_DB: pp POSTGRES_DB: pp
volumes: volumes:
- pp_db_data:/var/lib/postgresql/data - pp_db_data:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U pp"] test: ["CMD-SHELL", "pg_isready -U pp"]
interval: 10s interval: 10s
+12 -6
View File
@@ -1,17 +1,20 @@
# PP (PR Previews) - Environment Variables # PP (PR Previews) Environment Variables
# Copy to .env and fill in values # Copy to .env (for local dev) or set in docker-compose.yml environment
# PostgreSQL connection string # PostgreSQL connection string
DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp
# Session signing secret (at least 32 random chars) # Session signing secret (at least 32 random chars)
SESSION_SECRET=change_me_to_a_random_32_character_string # Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars
# Public URL of this PP instance (no trailing slash) # Public URL of this PP instance (no trailing slash). Used in webhook URLs and PR comment links.
PP_BASE_URL=https://pp.example.com PP_BASE_URL=https://pp.example.com
# AES-256 encryption key — 64 hex chars (32 bytes) # AES-256 encryption key — exactly 64 hex chars (32 bytes).
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Used to encrypt Gitea PAT, AWS credentials, and SSH private keys at rest.
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# WARNING: Changing this key will invalidate all encrypted data in the database.
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
# Backend port (default: 5000) # Backend port (default: 5000)
@@ -19,3 +22,6 @@ PORT=5000
# Log level: trace | debug | info | warn | error # Log level: trace | debug | info | warn | error
LOG_LEVEL=info LOG_LEVEL=info
# Node environment
NODE_ENV=production
+55 -2
View File
@@ -3,8 +3,61 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PR Previews</title> <meta
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -3
View File
@@ -2,6 +2,7 @@
"name": "pp-frontend", "name": "pp-frontend",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"packageManager": "pnpm@11.5.2+sha1.7ab39d363d1ca5b2fb97795f45291083da4a1393",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,18 +10,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.26.0",
"motion": "^12.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
"react-toastify": "^11.0.0", "react-toastify": "^11.0.0"
"motion": "^12.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.5.22",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^6.0.0" "vite": "^6.0.0"
+22 -22
View File
@@ -35,10 +35,10 @@ importers:
version: 4.7.0(vite@6.4.3(jiti@1.21.7)) version: 4.7.0(vite@6.4.3(jiti@1.21.7))
autoprefixer: autoprefixer:
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.5.4(postcss@8.5.23) version: 10.5.4(postcss@8.5.22)
postcss: postcss:
specifier: ^8.4.0 specifier: ^8.5.22
version: 8.5.23 version: 8.5.22
tailwindcss: tailwindcss:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.19 version: 3.4.19
@@ -820,8 +820,8 @@ packages:
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.23: postcss@8.5.22:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
queue-microtask@1.2.3: queue-microtask@1.2.3:
@@ -1347,13 +1347,13 @@ snapshots:
arg@5.0.2: {} arg@5.0.2: {}
autoprefixer@10.5.4(postcss@8.5.23): autoprefixer@10.5.4(postcss@8.5.22):
dependencies: dependencies:
browserslist: 4.28.7 browserslist: 4.28.7
caniuse-lite: 1.0.30001806 caniuse-lite: 1.0.30001806
fraction.js: 5.3.4 fraction.js: 5.3.4
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.23 postcss: 8.5.22
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
baseline-browser-mapping@2.11.1: {} baseline-browser-mapping@2.11.1: {}
@@ -1576,28 +1576,28 @@ snapshots:
pirates@4.0.7: {} pirates@4.0.7: {}
postcss-import@15.1.0(postcss@8.5.23): postcss-import@15.1.0(postcss@8.5.22):
dependencies: dependencies:
postcss: 8.5.23 postcss: 8.5.22
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
read-cache: 1.0.0 read-cache: 1.0.0
resolve: 1.22.12 resolve: 1.22.12
postcss-js@4.1.0(postcss@8.5.23): postcss-js@4.1.0(postcss@8.5.22):
dependencies: dependencies:
camelcase-css: 2.0.1 camelcase-css: 2.0.1
postcss: 8.5.23 postcss: 8.5.22
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23): postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.22):
dependencies: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
optionalDependencies: optionalDependencies:
jiti: 1.21.7 jiti: 1.21.7
postcss: 8.5.23 postcss: 8.5.22
postcss-nested@6.2.0(postcss@8.5.23): postcss-nested@6.2.0(postcss@8.5.22):
dependencies: dependencies:
postcss: 8.5.23 postcss: 8.5.22
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
postcss-selector-parser@6.1.4: postcss-selector-parser@6.1.4:
@@ -1607,7 +1607,7 @@ snapshots:
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.23: postcss@8.5.22:
dependencies: dependencies:
nanoid: 3.3.16 nanoid: 3.3.16
picocolors: 1.1.1 picocolors: 1.1.1
@@ -1732,11 +1732,11 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
object-hash: 3.0.0 object-hash: 3.0.0
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.23 postcss: 8.5.22
postcss-import: 15.1.0(postcss@8.5.23) postcss-import: 15.1.0(postcss@8.5.22)
postcss-js: 4.1.0(postcss@8.5.23) postcss-js: 4.1.0(postcss@8.5.22)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23) postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.22)
postcss-nested: 6.2.0(postcss@8.5.23) postcss-nested: 6.2.0(postcss@8.5.22)
postcss-selector-parser: 6.1.4 postcss-selector-parser: 6.1.4
resolve: 1.22.12 resolve: 1.22.12
sucrase: 3.35.1 sucrase: 3.35.1
@@ -1780,7 +1780,7 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.5) fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5 picomatch: 4.0.5
postcss: 8.5.23 postcss: 8.5.22
rollup: 4.62.2 rollup: 4.62.2
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies:
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 { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import { AuthContext, useAuthProvider } from "./hooks/useAuth"; import { AuthContext, useAuthProvider } from "./hooks/useAuth";
import { useTheme } from "./hooks/useTheme"; import { ThemeProvider, useTheme } from "./hooks/useTheme";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Overview } from "./pages/Overview";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { PreviewDetail } from "./pages/PreviewDetail"; import { PreviewDetail } from "./pages/PreviewDetail";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
import { Repos } from "./pages/Repos"; import { Repos } from "./pages/Repos";
import { RepoConfig } from "./pages/RepoConfig";
import { Admin } from "./pages/Admin"; import { Admin } from "./pages/Admin";
import { SetupWizard } from "./pages/SetupWizard"; import { SetupWizard } from "./pages/SetupWizard";
import { Privacy } from "./pages/Privacy"; import { Privacy } from "./pages/Privacy";
import { Loader2 } from "lucide-react";
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = React.useContext(AuthContext); const { user, loading } = React.useContext(AuthContext);
@@ -24,7 +27,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (loading) return ( if (loading) return (
<div className="min-h-screen flex items-center justify-center"> <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> </div>
); );
@@ -48,9 +51,8 @@ function SetupCheck({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
export default function App() { function AppContent({ auth }: { auth: ReturnType<typeof useAuthProvider> }) {
const auth = useAuthProvider(); const { resolvedTheme } = useTheme();
useTheme();
return ( return (
<AuthContext.Provider value={auth}> <AuthContext.Provider value={auth}>
@@ -60,7 +62,7 @@ export default function App() {
hideProgressBar={false} hideProgressBar={false}
newestOnTop newestOnTop
closeOnClick closeOnClick
theme="colored" theme={resolvedTheme}
/> />
<Routes> <Routes>
<Route path="/login" element={ <Route path="/login" element={
@@ -79,9 +81,11 @@ export default function App() {
<SetupCheck> <SetupCheck>
<Layout> <Layout>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Overview />} />
<Route path="/previews" element={<Dashboard />} />
<Route path="/previews/:id" element={<PreviewDetail />} /> <Route path="/previews/:id" element={<PreviewDetail />} />
<Route path="/repos" element={<Repos />} /> <Route path="/repos" element={<Repos />} />
<Route path="/repos/:owner/:repo" element={<RepoConfig />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Admin />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
@@ -94,3 +98,13 @@ export default function App() {
</AuthContext.Provider> </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 React from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useTheme } from "../hooks/useTheme"; import { ThemePreference, useTheme } from "../hooks/useTheme";
import { api } from "../services/api"; 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 }) { export function Layout({ children }: { children: React.ReactNode }) {
const { user, refresh } = useAuth(); const { user, refresh } = useAuth();
const { dark, toggle } = useTheme(); const { theme, setTheme } = useTheme();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); 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"> <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="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80"> <Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80 inline-flex items-center gap-1.5">
🚀 PR Previews <Rocket size={20} /> PR Previews
</Link> </Link>
</div> </div>
{user && ( {user && (
<nav className="flex items-center gap-1 overflow-x-auto"> <nav className="flex items-center gap-1 overflow-x-auto">
{navLink("/", "Previews")} {navLink("/", "Overview")}
{navLink("/previews", "Previews")}
{navLink("/repos", "Repos")} {navLink("/repos", "Repos")}
{navLink("/settings", "Settings")} {navLink("/settings", "Settings")}
{user.isAdmin && navLink("/admin", "Admin")} {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"> <div className="flex items-center gap-2 shrink-0">
<button <div
onClick={toggle} className="inline-flex items-center rounded-lg border border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800 p-1"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-slate-800 text-lg" role="group"
title="Toggle theme" aria-label="Theme preference"
> >
{dark ? "☀️" : "🌙"} {themeOptions.map(({ value, label, Icon }) => {
</button> 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 && ( {user && (
<button <button
onClick={handleLogout} onClick={handleLogout}
@@ -74,7 +101,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
</main> </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"> <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> <Link to="/privacy" className="underline hover:text-gray-700 dark:hover:text-slate-200">Privacy Policy</Link>
</footer> </footer>
</div> </div>
+27 -11
View File
@@ -1,18 +1,34 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
function stripAnsi(str: string): string { function stripAnsi(str: string): string {
return str.replace(/\x1B\[[\d;]*[mGKHFJsu]/g, "").replace(/\x1B\][^\x07]*\x07/g, ""); 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) { function renderLogLine(line: string, idx: number) {
if (line.startsWith("--- ") && (line.includes("Redeploy") || line.includes("truncated"))) { const match = line.match(TIMESTAMP_RE);
return ( const ts = match ? match[1] : null;
<span key={idx} className="text-slate-400 dark:text-slate-500 italic block"> const content = match ? match[2] : line;
{line}
</span> const isMarker =
); content.startsWith("--- ") &&
} (content.includes("Redeploy") || content.includes("truncated"));
return <span key={idx} className="block">{stripAnsi(line)}</span>;
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 { interface Props {
@@ -36,7 +52,7 @@ export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Prop
return ( return (
<div className="relative"> <div className="relative">
<div <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 }} style={{ maxHeight }}
onScroll={(e) => { onScroll={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
@@ -52,9 +68,9 @@ export function LogViewer({ logs, autoScroll = true, maxHeight = "500px" }: Prop
{!pinned && ( {!pinned && (
<button <button
onClick={() => { setPinned(true); endRef.current?.scrollIntoView({ behavior: "smooth" }); }} 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> </button>
)} )}
</div> </div>
+14 -10
View File
@@ -1,21 +1,25 @@
import React from "react"; import React from "react";
import { Loader2, CheckCircle2, XCircle, CircleSlash, type LucideIcon } from "lucide-react";
type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED"; type Status = "PROVISIONING" | "BUILDING" | "RUNNING" | "FAILED" | "STOPPED" | "IGNORED";
const CONFIG: Record<Status, { label: string; icon: string; cls: string }> = { const CONFIG: Record<Status, { label: string; icon: LucideIcon; spin?: boolean; cls: string }> = {
PROVISIONING: { label: "Provisioning", icon: "🟡", cls: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" }, 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: "🟡", 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: "🟢", cls: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-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: "🔴", cls: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-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: "⚫", cls: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-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: "⚫", 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 cfg = CONFIG[status] || CONFIG.STOPPED;
const Icon = cfg.icon;
const label = status === "STOPPED" && reason ? `${cfg.label}: ${reason}` : cfg.label;
return ( return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${cfg.cls}`}> <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}>
<span>{cfg.icon}</span> {cfg.label} <Icon size={12} className={`shrink-0 ${cfg.spin ? "animate-spin" : ""}`} />
<span className="truncate">{label}</span>
</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() { export type ThemePreference = "light" | "dark" | "system";
const [dark, setDark] = useState(() => { type ResolvedTheme = "light" | "dark";
const stored = localStorage.getItem("pp-theme");
if (stored) return stored === "dark"; interface ThemeContextValue {
return window.matchMedia("(prefers-color-scheme: dark)").matches; 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(() => { useEffect(() => {
document.documentElement.classList.toggle("dark", dark); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
localStorage.setItem("pp-theme", dark ? "dark" : "light"); const updateResolvedTheme = () => setResolvedTheme(theme === "system" ? (mediaQuery.matches ? "dark" : "light") : theme);
}, [dark]);
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);
}
+90 -10
View File
@@ -5,10 +5,23 @@ import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { Link } from "react-router-dom"; 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;
username: string;
newUsername: string;
newPassword: string;
isAdmin: boolean;
isFounder: boolean;
}
export function Admin() { export function Admin() {
const { user } = useAuth(); const { user } = useAuth();
const [tab, setTab] = useState<"users" | "settings" | "previews">("users"); const [tab, setTab] = useState<"users" | "settings" | "previews">("users");
usePageTitle(`Admin ${tab[0].toUpperCase()}${tab.slice(1)}`);
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<any>(null);
const [previews, setPreviews] = useState<any[]>([]); const [previews, setPreviews] = useState<any[]>([]);
@@ -16,7 +29,7 @@ export function Admin() {
const [newUsername, setNewUsername] = useState(""); const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [editUser, setEditUser] = useState<any>(null); const [editUser, setEditUser] = useState<EditUserForm | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<any>(null); const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [stopConfirm, setStopConfirm] = useState<any>(null); const [stopConfirm, setStopConfirm] = useState<any>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -27,7 +40,7 @@ export function Admin() {
}; };
const loadSettings = async () => { const loadSettings = async () => {
const res = await api.admin.getSettings(); 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 loadPreviews = async () => {
const res = await api.admin.listPreviews(); const res = await api.admin.listPreviews();
@@ -42,7 +55,7 @@ export function Admin() {
if (!user?.isAdmin) return ( if (!user?.isAdmin) return (
<div className="text-center py-20"> <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> <h2 className="text-xl font-semibold">Access Denied</h2>
</div> </div>
); );
@@ -64,6 +77,19 @@ export function Admin() {
else toast.error(res.message || "Failed to delete user"); else toast.error(res.message || "Failed to delete user");
}; };
const handleSaveUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!editUser) return;
setSaving(true);
const data: any = {};
if (editUser.newUsername && editUser.newUsername !== editUser.username) data.username = editUser.newUsername;
if (editUser.newPassword) data.password = editUser.newPassword;
const res = await api.admin.updateUser(editUser.id, data);
setSaving(false);
if (res.ok) { toast.success("User updated"); setEditUser(null); loadUsers(); }
else toast.error(res.message || "Failed to update user");
};
const handleSaveSettings = async (e: React.FormEvent) => { const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
@@ -102,6 +128,38 @@ export function Admin() {
danger danger
/> />
{/* Edit user modal */}
{editUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 w-full max-w-sm">
<h2 className="font-semibold text-lg mb-4">Edit User: {editUser.username}</h2>
<form onSubmit={handleSaveUser} className="space-y-3">
<div>
<label className={labelCls}>Username</label>
<input type="text" value={editUser.newUsername}
onChange={e => setEditUser(u => u ? { ...u, newUsername: e.target.value } : null)}
className={inputCls} />
</div>
<div>
<label className={labelCls}>New Password (leave blank to keep current)</label>
<input type="password" value={editUser.newPassword} placeholder="New password"
onChange={e => setEditUser(u => u ? { ...u, newPassword: e.target.value } : null)}
className={inputCls} />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving} className={`${btnCls} flex-1`}>
{saving ? "Saving..." : "Save Changes"}
</button>
<button type="button" onClick={() => setEditUser(null)}
className="flex-1 px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
Cancel
</button>
</div>
</form>
</div>
</div>
)}
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1> <h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
<div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-slate-700"> <div className="flex gap-2 mb-6 border-b border-gray-200 dark:border-slate-700">
@@ -123,7 +181,7 @@ export function Admin() {
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)} <input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
placeholder="Username" className={inputCls} required /> placeholder="Username" className={inputCls} required />
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} <input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
placeholder="Password" className={inputCls} required /> placeholder="Password (min 8 chars)" className={inputCls} required minLength={8} />
<button type="submit" disabled={saving} className={btnCls}>Create</button> <button type="submit" disabled={saving} className={btnCls}>Create</button>
</form> </form>
</div> </div>
@@ -153,6 +211,12 @@ export function Admin() {
<td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(u.createdAt).toLocaleDateString()}</td> <td className="px-4 py-3 text-gray-500 dark:text-slate-400">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button
onClick={() => setEditUser({ id: u.id, username: u.username, newUsername: u.username, newPassword: "", isAdmin: u.isAdmin, isFounder: u.isFounder })}
className="text-xs text-gray-600 dark:text-slate-300 hover:underline"
>
Edit
</button>
{!u.isFounder && u.id !== user.id && ( {!u.isFounder && u.id !== user.id && (
<> <>
<button <button
@@ -184,9 +248,19 @@ export function Admin() {
<h2 className="font-semibold mb-4">Global Settings</h2> <h2 className="font-semibold mb-4">Global Settings</h2>
<form onSubmit={handleSaveSettings} className="space-y-4"> <form onSubmit={handleSaveSettings} className="space-y-4">
<Field label="Default EC2 Instance Type"> <Field label="Default EC2 Instance Type">
<input type="text" value={settings.defaultInstanceType} <select
onChange={e => setSettings((s: any) => ({ ...s, defaultInstanceType: e.target.value }))} value={isPresetInstanceType(settings.defaultInstanceType) ? settings.defaultInstanceType : "custom"}
className={inputCls} /> 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>
<Field label="Max Concurrent Instances Per User"> <Field label="Max Concurrent Instances Per User">
<input type="number" value={settings.maxConcurrentInstancesPerUser} <input type="number" value={settings.maxConcurrentInstancesPerUser}
@@ -208,7 +282,7 @@ export function Admin() {
onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))} onChange={e => setSettings((s: any) => ({ ...s, previewRetentionDays: Number(e.target.value) }))}
className={inputCls} min={1} /> className={inputCls} min={1} />
</Field> </Field>
<Field label="Contact Email"> <Field label="Contact Email (shown in privacy policy)">
<input type="email" value={settings.contactEmail} <input type="email" value={settings.contactEmail}
onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))} onChange={e => setSettings((s: any) => ({ ...s, contactEmail: e.target.value }))}
className={inputCls} placeholder="admin@example.com" /> className={inputCls} placeholder="admin@example.com" />
@@ -224,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="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"> <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> <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>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -246,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> <Link to={`/previews/${p.id}`} className="font-medium hover:text-blue-600 dark:hover:text-blue-400">PR #{p.prNumber}</Link>
</td> </td>
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{p.user?.username}</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"> <td className="px-4 py-3">
{p.instanceIp ? ( {p.instanceIp ? (
<a href={`http://${p.instanceIp}:${p.port}`} target="_blank" rel="noreferrer" <a href={`http://${p.instanceIp}:${p.port}`} target="_blank" rel="noreferrer"
@@ -264,6 +338,11 @@ export function Admin() {
</td> </td>
</tr> </tr>
))} ))}
{previews.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500 dark:text-slate-400">No previews yet.</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -282,5 +361,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
); );
} }
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"; 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";
const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50"; const btnCls = "px-4 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50";
+104 -49
View File
@@ -1,9 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../services/api"; import { api, formatUsd } from "../services/api";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { Settings2, Search, RefreshCw, ArrowRight, ExternalLink, ChevronDown, ChevronRight } from "lucide-react";
interface Preview { interface Preview {
id: number; id: number;
@@ -11,6 +13,7 @@ interface Preview {
prTitle: string; prTitle: string;
commitSha: string; commitSha: string;
status: string; status: string;
stopReason: string | null;
instanceIp: string | null; instanceIp: string | null;
port: number; port: number;
createdAt: string; createdAt: string;
@@ -18,12 +21,67 @@ interface Preview {
lastActivityAt: string; lastActivityAt: string;
repoOwner: string; repoOwner: string;
repoName: 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() { export function Dashboard() {
const [previews, setPreviews] = useState<Preview[]>([]); const [previews, setPreviews] = useState<Preview[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showOld, setShowOld] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
usePageTitle("Previews");
const load = async () => { const load = async () => {
const res = await api.previews.list(); const res = await api.previews.list();
@@ -40,7 +98,7 @@ export function Dashboard() {
if (!user?.setupComplete && !loading) { if (!user?.setupComplete && !loading) {
return ( return (
<div className="text-center py-20"> <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> <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> <p className="text-gray-600 dark:text-slate-400 mb-6">Configure your Gitea and AWS credentials to get started.</p>
<Link <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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Previews</h1> <div>
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> <h1 className="text-2xl font-bold">Previews</h1>
Refresh {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> </button>
</div> </div>
@@ -70,59 +139,45 @@ export function Dashboard() {
</div> </div>
) : previews.length === 0 ? ( ) : previews.length === 0 ? (
<div className="text-center py-20"> <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> <h2 className="text-xl font-semibold mb-2">No previews yet</h2>
<p className="text-gray-500 dark:text-slate-400 mb-4"> <p className="text-gray-500 dark:text-slate-400 mb-4">
Enable a repo and open a pull request to create your first preview. Enable a repo and open a pull request to create your first preview.
</p> </p>
<Link to="/repos" className="text-blue-600 dark:text-blue-400 hover:underline"> <Link to="/repos" className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline">
Configure repos Configure repos <ArrowRight size={14} />
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <>
{previews.map((p, i) => ( {active.length > 0 ? (
<motion.div <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
key={p.id} {active.map((p, i) => <PreviewCard key={p.id} p={p} i={i} />)}
initial={{ opacity: 0, y: 10 }} </div>
animate={{ opacity: 1, y: 0 }} ) : (
transition={{ delay: i * 0.04 }} <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>
<Link </div>
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"
{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"> {showOld ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div> {showOld ? "Hide" : "Show"} stopped &amp; old previews ({old.length})
<p className="text-xs text-gray-500 dark:text-slate-400">{p.repoOwner}/{p.repoName}</p> </button>
<p className="font-semibold text-sm mt-0.5 line-clamp-1">PR #{p.prNumber}: {p.prTitle}</p>
</div> {showOld && (
<StatusBadge status={p.status as any} /> <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> </div>
)}
<p className="text-xs text-gray-500 dark:text-slate-400 mb-2"> </div>
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 { useNavigate } from "react-router-dom";
import { api } from "../services/api"; import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Rocket, Loader2 } from "lucide-react";
export function Login() { export function Login() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -13,6 +15,7 @@ export function Login() {
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null); const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
const { refresh } = useAuth(); const { refresh } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
usePageTitle(needsSetup ? "Create Admin Account" : "Sign In");
useEffect(() => { useEffect(() => {
api.auth.setupStatus().then(res => { api.auth.setupStatus().then(res => {
@@ -46,7 +49,7 @@ export function Login() {
if (needsSetup === null) { if (needsSetup === null) {
return <div className="min-h-screen flex items-center justify-center"> 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>; </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" 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-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> <h1 className="text-2xl font-bold">PR Previews</h1>
{needsSetup ? ( {needsSetup ? (
<p className="text-sm text-blue-600 dark:text-blue-400 mt-1 font-medium">Create your admin account</p> <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 React, { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate, Link } from "react-router-dom"; 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 { StatusBadge } from "../components/StatusBadge";
import { LogViewer } from "../components/LogViewer"; import { LogViewer } from "../components/LogViewer";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { ArrowLeft, CheckCircle2, RefreshCw } from "lucide-react";
interface Job { interface Job {
id: number; id: number;
@@ -22,6 +24,7 @@ interface Preview {
prTitle: string; prTitle: string;
commitSha: string; commitSha: string;
status: string; status: string;
stopReason: string | null;
instanceIp: string | null; instanceIp: string | null;
port: number; port: number;
logs: string; logs: string;
@@ -31,6 +34,9 @@ interface Preview {
lastActivityAt: string; lastActivityAt: string;
repoOwner: string; repoOwner: string;
repoName: string; repoName: string;
instanceType: string | null;
costUsd: number;
costRateUsd: number;
jobs: Job[]; jobs: Job[];
} }
@@ -40,9 +46,13 @@ export function PreviewDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const [preview, setPreview] = useState<Preview | null>(null); const [preview, setPreview] = useState<Preview | null>(null);
const [logs, setLogs] = useState(""); const [logs, setLogs] = useState("");
const [appLogs, setAppLogs] = useState("");
const [logTab, setLogTab] = useState<"deploy" | "app">("deploy");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [confirmStop, setConfirmStop] = useState(false); const [confirmStop, setConfirmStop] = useState(false);
const [stopping, setStopping] = 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 loadPreview = useCallback(async () => {
const res = await api.previews.get(previewId); const res = await api.previews.get(previewId);
@@ -50,7 +60,7 @@ export function PreviewDetail() {
setPreview(res.data as Preview); setPreview(res.data as Preview);
setLogs(res.data.logs || ""); setLogs(res.data.logs || "");
} else if (res.status === 404) { } else if (res.status === 404) {
navigate("/"); navigate("/previews");
} }
setLoading(false); setLoading(false);
}, [previewId, navigate]); }, [previewId, navigate]);
@@ -73,6 +83,19 @@ export function PreviewDetail() {
return () => { try { ws.close(); } catch {} }; return () => { try { ws.close(); } catch {} };
}, [preview?.status, previewId]); }, [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 () => { const handleStop = async () => {
setStopping(true); setStopping(true);
const res = await api.previews.stop(previewId); const res = await api.previews.stop(previewId);
@@ -83,6 +106,15 @@ export function PreviewDetail() {
await loadPreview(); 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) { if (loading) {
return ( return (
<div className="space-y-4 animate-pulse"> <div className="space-y-4 animate-pulse">
@@ -108,8 +140,8 @@ export function PreviewDetail() {
/> />
<div> <div>
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-2 inline-block"> <Link to="/previews" className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-2">
Back to Previews <ArrowLeft size={14} /> Back to Previews
</Link> </Link>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
@@ -119,7 +151,17 @@ export function PreviewDetail() {
<p className="text-gray-600 dark:text-slate-300 mt-1">{preview.prTitle}</p> <p className="text-gray-600 dark:text-slate-300 mt-1">{preview.prTitle}</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <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" && ( {preview.status !== "STOPPED" && preview.status !== "IGNORED" && (
<button <button
onClick={() => setConfirmStop(true)} onClick={() => setConfirmStop(true)}
@@ -138,11 +180,22 @@ export function PreviewDetail() {
<InfoCard label="Port" value={String(preview.port)} /> <InfoCard label="Port" value={String(preview.port)} />
<InfoCard label="Created" value={new Date(preview.createdAt).toLocaleDateString()} /> <InfoCard label="Created" value={new Date(preview.createdAt).toLocaleDateString()} />
<InfoCard label="Last Activity" value={new Date(preview.lastActivityAt).toLocaleString()} /> <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> </div>
{preview.status === "RUNNING" && preview.instanceIp && ( {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"> <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 <a
href={`http://${preview.instanceIp}:${preview.port}`} href={`http://${preview.instanceIp}:${preview.port}`}
target="_blank" target="_blank"
@@ -155,8 +208,42 @@ export function PreviewDetail() {
)} )}
<div> <div>
<h2 className="text-lg font-semibold mb-3">Logs</h2> <div className="flex items-center gap-2 mb-3">
<LogViewer logs={logs} autoScroll maxHeight="600px" /> <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>
<div> <div>
+145 -30
View File
@@ -1,41 +1,156 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom"; 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() { export function Privacy() {
const [contactEmail, setContactEmail] = useState<string | null>(null);
usePageTitle("Privacy Policy");
useEffect(() => {
api.admin.getSettings().then(res => {
if (res.ok && res.data?.contactEmail) setContactEmail(res.data.contactEmail);
}).catch(() => {});
}, []);
return ( return (
<div className="max-w-2xl prose dark:prose-invert"> <div className="mx-auto max-w-5xl space-y-6">
<Link to="/" className="text-sm text-blue-600 dark:text-blue-400 hover:underline mb-4 inline-block"> Back</Link> <Link to="/" className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline">
<h1>Privacy Policy</h1> <ArrowLeft size={16} aria-hidden="true" />
<p>This is a self-hosted instance of PR Previews (PP). The following describes what data PP stores and how it is used.</p> Back
</Link>
<h2>What We Store</h2> <section className="border-b border-gray-200 dark:border-slate-700 pb-6">
<ul> <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">
<li>Your username and hashed password.</li> <ShieldCheck size={14} aria-hidden="true" />
<li>Your Gitea Personal Access Token (PAT), encrypted at rest with AES-256.</li> Self-hosted privacy policy
<li>Your AWS Access Key ID and Secret Access Key, encrypted at rest with AES-256.</li> </div>
<li>Preview logs, PR metadata (PR number, title, commit SHA), and EC2 instance details.</li> <h1 className="mt-4 text-3xl font-bold tracking-tight text-gray-950 dark:text-slate-50 sm:text-4xl">
<li>SSH private keys (ephemeral per launch, encrypted at rest, deleted on instance termination).</li> Privacy Policy
</ul> </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> <div className="grid gap-4 lg:grid-cols-2">
<ul> {policySections.map(({ title, Icon, items }) => (
<li>Your Gitea PAT is used solely to register webhooks, clone repositories, and post preview status comments on PRs.</li> <section key={title} className="rounded-xl border border-gray-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-800">
<li>Your AWS credentials are used solely to provision EC2 instances for previews in your own AWS account.</li> <div className="mb-4 flex items-center gap-3">
<li>PP does not have access to data on EC2 instances beyond what it deploys.</li> <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">
<li>No data is shared with third parties.</li> <Icon size={20} aria-hidden="true" />
</ul> </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> <div className="grid gap-4 md:grid-cols-2">
<p>Preview records 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> {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> <section className="rounded-xl border border-blue-200 bg-blue-50 p-5 dark:border-blue-900/70 dark:bg-blue-950/30">
<p>Preview instances are launched in your own AWS account. PP terminates them on PR close, inactivity timeout, or manual stop. PP does not retain any data from inside EC2 instances.</p> <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">
<h2>Analytics & Tracking</h2> <Mail size={20} aria-hidden="true" />
<p>No analytics, no tracking, no external data sharing. PP is fully self-contained.</p> </span>
<div>
<h2>Contact</h2> <h2 className="text-base font-semibold text-gray-950 dark:text-slate-50">Contact</h2>
<p>For questions or concerns, contact the instance administrator.</p> {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> </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 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 { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { usePageTitle } from "../hooks/usePageTitle";
const INSTANCE_TYPES = [ import { RefreshCw, Link2, Search, X, Lock, Settings } from "lucide-react";
{ 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" },
];
interface Repo { interface Repo {
owner: string; owner: string;
name: string; name: string;
fullName: string; fullName: string;
htmlUrl: string; htmlUrl: string;
isPrivate: boolean;
isEnabled: boolean; isEnabled: boolean;
claimedByOther: boolean; claimedByOther: boolean;
config: any; config: any;
costUsd: number;
} }
type VisibilityFilter = "all" | "public" | "private";
type StatusFilter = "all" | "enabled" | "disabled";
export function Repos() { export function Repos() {
const navigate = useNavigate();
usePageTitle("Repositories");
const [repos, setRepos] = useState<Repo[]>([]); const [repos, setRepos] = useState<Repo[]>([]);
const [loading, setLoading] = useState(true); 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 [disableConfirm, setDisableConfirm] = useState<{ owner: string; repo: string } | null>(null);
const [togglingRepo, setTogglingRepo] = useState<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 () => { const load = async () => {
setLoading(true); setLoading(true);
const res = await api.repos.list(); const res = await api.repos.list();
if (res.ok) { if (res.ok) {
const data = (res.data || []) as Repo[]; setRepos((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);
} else { } else {
toast.error(res.message || "Failed to load repos"); toast.error(res.message || "Failed to load repos");
} }
@@ -51,23 +51,6 @@ export function Repos() {
useEffect(() => { load(); }, []); 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 handleToggle = async (repo: Repo) => {
const key = `${repo.owner}/${repo.name}`; const key = `${repo.owner}/${repo.name}`;
if (repo.isEnabled) { if (repo.isEnabled) {
@@ -92,26 +75,26 @@ export function Repos() {
else toast.error(res.message || "Failed to disable repo"); 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) { if (loading) {
return <div className="space-y-3"> 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" />)} {[...Array(4)].map((_, i) => <div key={i} className="h-16 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />)}
</div>; </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 ( return (
<div className="max-w-3xl space-y-4"> <div className="max-w-3xl space-y-4">
<ConfirmDialog <ConfirmDialog
@@ -126,221 +109,144 @@ export function Repos() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Repo Configuration</h1> <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> </div>
{repos.length === 0 && ( {repos.length === 0 && (
<div className="text-center py-16"> <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> <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> <p className="text-gray-500 dark:text-slate-400">Configure your Gitea credentials in Settings first.</p>
</div> </div>
)} )}
{repos.map(repo => { {repos.length > 0 && (
const key = `${repo.owner}/${repo.name}`; <div className="flex flex-col sm:flex-row gap-2">
const expanded = expandedRepo === key; <div className="relative flex-1">
const config = configs[key] || defaultConfig(); <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
const isToggling = togglingRepo === key; <input
type="text"
return ( value={search}
<div key={key} className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl overflow-hidden"> onChange={e => setSearch(e.target.value)}
<div className="p-4 flex items-center gap-3"> placeholder="Search repos..."
<div className="flex-1 min-w-0"> className={`${inputCls} pl-9`}
<p className="font-medium text-sm">{repo.fullName}</p> />
{repo.claimedByOther && ( {search && (
<span className="text-xs text-amber-600 dark:text-amber-400">🔒 Claimed by another user</span> <button
)} onClick={() => setSearch("")}
</div> className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-200"
<div className="flex items-center gap-2"> title="Clear search"
{!repo.claimedByOther && ( ><X size={16} /></button>
<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>
)} )}
</div> </div>
); <select value={visibility} onChange={e => setVisibility(e.target.value as VisibilityFilter)} className={`${inputCls} sm:w-40`}>
})} <option value="all">All visibility</option>
</div> <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 }) { {repos.length > 0 && filteredRepos.length === 0 && (
return ( <div className="text-center py-12 text-gray-500 dark:text-slate-400">
<div> No repos match your search or filters.
<label className={labelCls}>{label}</label> </div>
<div className="space-y-1"> )}
{value.map((cmd, i) => (
<div key={i} className="flex gap-2"> {groupedSections.map(section => (
<input type="text" value={cmd} <section key={section.label} className="space-y-3">
onChange={e => { const n = [...value]; n[i] = e.target.value; onChange(n); }} <div className="flex items-center gap-2 pt-2">
className={inputCls} placeholder="npm run build" /> <h2 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400">{section.label}</h2>
<button onClick={() => onChange(value.filter((_, j) => j !== i))} className="text-red-500 hover:text-red-700 px-2"></button> <div className="h-px flex-1 bg-gray-200 dark:bg-slate-700" />
</div> </div>
))}
<button onClick={() => onChange([...value, ""])} className="text-sm text-blue-600 dark:text-blue-400 hover:underline"> {section.repos.map(group => (
+ Add command <div key={`${section.label}-${group.owner}`} className="space-y-2">
</button> <div className="flex items-center gap-2 px-1">
</div> <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> </div>
); );
} }
function EnvVarsEditor({ value, onChange }: { value: Record<string, string>; onChange: (v: Record<string, string>) => void }) { function groupReposByOwner(repos: Repo[]) {
const entries = Object.entries(value); const ownerMap = new Map<string, Repo[]>();
const addEntry = () => onChange({ ...value, "": "" }); for (const repo of repos) {
const updateEntry = (oldKey: string, newKey: string, newVal: string) => { ownerMap.set(repo.owner, [...(ownerMap.get(repo.owner) ?? []), repo]);
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 ( return [...ownerMap.entries()]
<div> .sort(([a], [b]) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
<label className={labelCls}>Environment Variables</label> .map(([owner, ownerRepos]) => ({
<div className="space-y-1"> owner,
{entries.map(([k, v], i) => ( repos: ownerRepos.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true })),
<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>
);
} }
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"; 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";
+3 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { api } from "../services/api"; import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
@@ -13,6 +14,7 @@ const AWS_REGIONS = [
export function Settings() { export function Settings() {
const { user, refresh } = useAuth(); const { user, refresh } = useAuth();
usePageTitle("Settings");
const [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -280,7 +282,7 @@ const IAM_POLICY = `{
"ec2:DeleteSecurityGroup", "ec2:DeleteSecurityGroup",
"ec2:AuthorizeSecurityGroupIngress", "ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups", "ec2:DescribeSecurityGroups",
"ec2:ImportKeyPair", "ec2:CreateKeyPair",
"ec2:DeleteKeyPair", "ec2:DeleteKeyPair",
"ec2:CreateTags", "ec2:CreateTags",
"sts:GetCallerIdentity" "sts:GetCallerIdentity"
+32 -17
View File
@@ -1,9 +1,11 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api } from "../services/api"; import { api } from "../services/api";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { usePageTitle } from "../hooks/usePageTitle";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { motion, AnimatePresence } from "motion/react"; 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"]; const STEPS = ["Welcome", "Gitea Connection", "AWS Setup", "Your Webhook", "Enable a Repo", "Done"];
@@ -27,6 +29,19 @@ export function SetupWizard() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [webhookUrl, setWebhookUrl] = useState(""); const [webhookUrl, setWebhookUrl] = useState("");
const [webhookSecret, setWebhookSecret] = useState(""); const [webhookSecret, setWebhookSecret] = useState("");
usePageTitle(`Setup | ${STEPS[step]}`);
// Auto-load webhook info whenever we reach step 3 (index 3 = "Your Webhook")
useEffect(() => {
if (step !== 3) return;
if (webhookSecret) return;
api.user.getWebhookSecret().then(res => {
if (res.ok && res.data) {
setWebhookUrl(res.data.webhookUrl);
setWebhookSecret(res.data.token);
}
}).catch(() => {});
}, [step, user?.id]);
const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1)); const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
const skip = () => navigate("/"); const skip = () => navigate("/");
@@ -53,7 +68,7 @@ export function SetupWizard() {
refresh(); refresh();
const secretRes = await api.user.getWebhookSecret(); const secretRes = await api.user.getWebhookSecret();
if (secretRes.ok && secretRes.data) { if (secretRes.ok && secretRes.data) {
setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`); setWebhookUrl(secretRes.data.webhookUrl);
setWebhookSecret(secretRes.data.token); setWebhookSecret(secretRes.data.token);
} }
next(); next();
@@ -87,15 +102,15 @@ export function SetupWizard() {
> >
{step === 0 && ( {step === 0 && (
<div className="text-center space-y-4"> <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> <h2 className="text-2xl font-bold">Welcome to PR Previews</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm"> <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. PP automatically provisions EC2 instances, builds your code, and posts live preview URLs on every pull request.
</p> </p>
<p className="text-sm text-gray-500 dark:text-slate-400">Let's get you set up in a few steps.</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"> <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"> <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 Get Started <ArrowRight size={16} />
</button> </button>
<button onClick={skip} className="px-4 py-2.5 text-sm text-gray-500 dark:text-slate-400 hover:underline"> <button onClick={skip} className="px-4 py-2.5 text-sm text-gray-500 dark:text-slate-400 hover:underline">
Skip wizard Skip wizard
@@ -115,12 +130,12 @@ export function SetupWizard() {
<input type="password" value={giteaPAT} onChange={e => setGiteaPAT(e.target.value)} <input type="password" value={giteaPAT} onChange={e => setGiteaPAT(e.target.value)}
className={inputCls} placeholder="Personal Access Token" /> className={inputCls} placeholder="Personal Access Token" />
<p className="text-xs text-gray-400 dark:text-slate-500"> <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> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={handleGiteaNext} disabled={saving || !giteaUrl || !giteaUsername || !giteaPAT} <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"> 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>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button> <button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div> </div>
@@ -148,7 +163,7 @@ export function SetupWizard() {
"Action": ["ec2:RunInstances","ec2:TerminateInstances", "Action": ["ec2:RunInstances","ec2:TerminateInstances",
"ec2:DescribeInstances","ec2:CreateSecurityGroup", "ec2:DescribeInstances","ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress", "ec2:DeleteSecurityGroup","ec2:AuthorizeSecurityGroupIngress",
"ec2:DescribeSecurityGroups","ec2:ImportKeyPair", "ec2:DescribeSecurityGroups","ec2:CreateKeyPair",
"ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"], "ec2:DeleteKeyPair","ec2:CreateTags","sts:GetCallerIdentity"],
"Resource": "*" "Resource": "*"
}] }]
@@ -158,7 +173,7 @@ export function SetupWizard() {
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={handleAwsNext} disabled={saving || !awsKeyId || !awsSecret} <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"> 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>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button> <button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div> </div>
@@ -174,7 +189,7 @@ export function SetupWizard() {
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Webhook URL (Target URL in Gitea)</label> <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`} /> className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div> </div>
<div> <div>
@@ -183,8 +198,8 @@ export function SetupWizard() {
className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} /> className={`${inputCls} font-mono text-xs bg-gray-50 dark:bg-slate-700/50`} />
</div> </div>
</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"> <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 Next <ArrowRight size={16} />
</button> </button>
</div> </div>
)} )}
@@ -197,8 +212,8 @@ export function SetupWizard() {
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<a href="/repos" <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"> 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 Go to Repos <ArrowRight size={16} />
</a> </a>
<button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button> <button onClick={next} className="text-sm text-gray-500 dark:text-slate-400 hover:underline px-2">Skip</button>
</div> </div>
@@ -207,13 +222,13 @@ export function SetupWizard() {
{step === 5 && ( {step === 5 && (
<div className="text-center space-y-4"> <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> <h2 className="text-2xl font-bold">You're all set!</h2>
<p className="text-gray-600 dark:text-slate-300 text-sm"> <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. Open a pull request on an enabled repo and PP will provision a preview automatically.
</p> </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"> <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 Go to Dashboard <ArrowRight size={16} />
</button> </button>
</div> </div>
)} )}
+25
View File
@@ -46,10 +46,14 @@ export const api = {
saveConfig: (data: any) => request("POST", "/repos/config", data), saveConfig: (data: any) => request("POST", "/repos/config", data),
toggle: (owner: string, repo: string, enabled: boolean) => request("POST", "/repos/toggle", { owner, repo, enabled }), toggle: (owner: string, repo: string, enabled: boolean) => request("POST", "/repos/toggle", { owner, repo, enabled }),
}, },
stats: {
get: () => request("GET", "/stats"),
},
previews: { previews: {
list: () => request("GET", "/previews"), list: () => request("GET", "/previews"),
get: (id: number) => request("GET", `/previews/${id}`), get: (id: number) => request("GET", `/previews/${id}`),
stop: (id: number) => request("POST", `/previews/${id}/stop`), stop: (id: number) => request("POST", `/previews/${id}/stop`),
rebuild: (id: number) => request("POST", `/previews/${id}/rebuild`),
}, },
admin: { admin: {
listUsers: () => request("GET", "/admin/users"), 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 { export function openLogsWs(previewId: number, onMessage: (msg: any) => void, onClose?: () => void): WebSocket {
const protocol = location.protocol === "https:" ? "wss:" : "ws:"; const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/api/previews/${previewId}/logs`); 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 || (() => {}); ws.onclose = onClose || (() => {});
return ws; 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

+4
View File
@@ -1,4 +1,8 @@
{ {
"name": "pp",
"private": true,
"license": "Apache-2.0",
"packageManager": "pnpm@11.5.2+sha1.7ab39d363d1ca5b2fb97795f45291083da4a1393",
"devDependencies": { "devDependencies": {
"prisma": "^6.19.3" "prisma": "^6.19.3"
}, },
+3736
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -1,3 +1,19 @@
packages: packages:
- backend - backend
- frontend - frontend
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
bufferutil: true
cpu-features: true
esbuild: true
prisma: true
ssh2: true
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- bufferutil
- cpu-features
- esbuild
- prisma
- ssh2
@@ -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/*';
+19 -3
View File
@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -72,10 +73,15 @@ model RepoConfig {
giteaWebhookId String? @db.VarChar(128) giteaWebhookId String? @db.VarChar(128)
denyList String[] 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) inactivityHours Float @default(12)
port Int @default(3000) port Int @default(3000)
envVars Json @default("{}") envVars Json @default("{}")
preinstallTools String[] @default([])
nodeVersion String? @db.VarChar(64)
useDockerCompose Boolean @default(false) useDockerCompose Boolean @default(false)
composeFilePath String? @db.VarChar(512) composeFilePath String? @db.VarChar(512)
aptPackages String[] aptPackages String[]
@@ -102,7 +108,17 @@ model Preview {
instanceId String? @db.VarChar(64) instanceId String? @db.VarChar(64)
instanceIp String? @db.VarChar(64) instanceIp String? @db.VarChar(64)
port Int @default(3000) 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) status PreviewStatus @default(PROVISIONING)
stopReason String? @db.VarChar(128)
logs String @default("") @db.Text logs String @default("") @db.Text
pid Int? pid Int?
sshPrivateKey String? @db.Text sshPrivateKey String? @db.Text
@@ -151,7 +167,7 @@ model NoConfigComment {
model AdminSettings { model AdminSettings {
id Int @id @default(1) 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) maxConcurrentInstancesPerUser Int @default(5)
logSizeLimitBytes Int @default(1048576) logSizeLimitBytes Int @default(1048576)
previewRetentionDays Int @default(30) previewRetentionDays Int @default(30)