feat: initial scaffold - backend, frontend, Prisma schema, Docker
- Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings - Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers - Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page - Docker Compose and Dockerfile for self-hosted deployment - Uses bcryptjs for Node 24 compatibility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../services/api";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { motion } from "motion/react";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
interface Preview {
|
||||
id: number;
|
||||
prNumber: number;
|
||||
prTitle: string;
|
||||
commitSha: string;
|
||||
status: string;
|
||||
instanceIp: string | null;
|
||||
port: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [previews, setPreviews] = useState<Preview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.previews.list();
|
||||
if (res.ok) setPreviews(res.data || []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
const t = setInterval(load, 10000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
if (!user?.setupComplete && !loading) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-5xl mb-4">⚙️</div>
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold">Previews</h1>
|
||||
<button onClick={load} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-40 bg-gray-100 dark:bg-slate-800 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : previews.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-5xl mb-4">🔍</div>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<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} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user