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

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