Init: First Commit

第一条提交
This commit is contained in:
Paul
2026-03-11 16:28:37 +08:00
commit 6ff64d4c80
28 changed files with 3742 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Paul CI/CD</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"lucide-react": "^0.469.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+20
View File
@@ -0,0 +1,20 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Projects from "./pages/Projects";
import ProjectDetail from "./pages/ProjectDetail";
import BuildDetail from "./pages/BuildDetail";
import Layout from "./components/Layout";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
<Route path="/builds/:id" element={<BuildDetail />} />
</Route>
</Routes>
</BrowserRouter>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Outlet, NavLink } from "react-router-dom";
import { GitBranch } from "lucide-react";
export default function Layout() {
return (
<div className="min-h-screen bg-gray-950 text-gray-100">
<header className="border-b border-gray-800 px-6 py-4 flex items-center gap-3">
<GitBranch className="w-5 h-5 text-blue-400" />
<NavLink
to="/projects"
className="text-lg font-semibold tracking-tight"
>
Paul CI/CD
</NavLink>
</header>
<main className="max-w-5xl mx-auto px-6 py-8">
<Outlet />
</main>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+112
View File
@@ -0,0 +1,112 @@
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
interface Build {
id: number;
project_id: number;
status: string;
trigger_ref: string;
started_at: number;
finished_at: number;
}
const STATUS_COLOR: Record<string, string> = {
success: "text-green-400",
failed: "text-red-400",
running: "text-yellow-400",
pending: "text-gray-400",
};
export default function BuildDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [build, setBuild] = useState<Build | null>(null);
const [log, setLog] = useState("");
const [status, setStatus] = useState("pending");
const logRef = useRef<HTMLPreElement>(null);
useEffect(() => {
fetch(`/api/builds/${id}`)
.then((r) => r.json())
.then((b) => {
setBuild(b);
setStatus(b.status);
});
}, [id]);
useEffect(() => {
const es = new EventSource(`/api/builds/${id}/log`);
es.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.log) setLog(data.log);
if (data.chunk) setLog((prev) => prev + data.chunk);
if (data.status) setStatus(data.status);
if (data.done) es.close();
};
es.onerror = () => es.close();
return () => es.close();
}, [id]);
// Auto-scroll to bottom
useEffect(() => {
if (logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [log]);
const duration =
build?.started_at && build?.finished_at
? `${build.finished_at - build.started_at}s`
: null;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
onClick={() =>
build
? navigate(`/projects/${build.project_id}`)
: navigate("/projects")
}
className="text-gray-400 hover:text-gray-200 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
<h1 className="text-xl font-semibold">Build #{id}</h1>
<span
className={`text-sm font-medium ${STATUS_COLOR[status] ?? "text-gray-400"}`}
>
{status}
</span>
</div>
{build && (
<div className="flex gap-6 text-xs text-gray-500">
<span>
ref:{" "}
<span className="text-gray-300 font-mono">
{build.trigger_ref || "—"}
</span>
</span>
{duration && (
<span>
duration: <span className="text-gray-300">{duration}</span>
</span>
)}
</div>
)}
<pre
ref={logRef}
className="bg-gray-900 border border-gray-800 rounded-lg p-4 text-xs font-mono text-gray-300 overflow-auto max-h-[70vh] whitespace-pre-wrap break-words"
>
{log || (status === "pending" ? "Waiting to start..." : "No output.")}
{status === "running" && <span className="animate-pulse">▌</span>}
</pre>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import {
Copy,
RefreshCw,
Save,
ArrowLeft,
Play,
Eye,
EyeOff,
} from "lucide-react";
interface Project {
id: number;
name: string;
webhook_secret: string;
trigger_branch: string;
shell_script: string;
}
interface Build {
id: number;
status: string;
trigger_ref: string;
started_at: number;
finished_at: number;
}
const STATUS_COLOR: Record<string, string> = {
success: "text-green-400",
failed: "text-red-400",
running: "text-yellow-400",
pending: "text-gray-400",
};
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
const [builds, setBuilds] = useState<Build[]>([]);
const [form, setForm] = useState({
name: "",
trigger_branch: "",
shell_script: "",
});
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
const [secretVisible, setSecretVisible] = useState(false);
const [triggering, setTriggering] = useState(false);
const load = async () => {
const [p, b] = await Promise.all([
fetch(`/api/projects/${id}`).then((r) => r.json()),
fetch(`/api/projects/${id}/builds`).then((r) => r.json()),
]);
setProject(p);
setBuilds(b);
setForm({
name: p.name,
trigger_branch: p.trigger_branch,
shell_script: p.shell_script,
});
};
useEffect(() => {
load();
}, [id]);
const save = async () => {
await fetch(`/api/projects/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
load();
};
const regenerateSecret = async () => {
if (
!confirm("Regenerate webhook secret? The old secret will stop working.")
)
return;
await fetch(`/api/projects/${id}/regenerate-secret`, { method: "POST" });
load();
};
const copySecret = () => {
navigator.clipboard.writeText(project?.webhook_secret ?? "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const triggerBuild = async () => {
setTriggering(true);
const res = await fetch(`/api/projects/${id}/trigger`, { method: "POST" });
const data = await res.json();
setTriggering(false);
if (data.buildId) navigate(`/builds/${data.buildId}`);
load();
};
const webhookUrl = `${window.location.origin}/api/webhook/${id}`;
if (!project) return <p className="text-gray-500 text-sm">Loading...</p>;
return (
<div className="space-y-8">
<div className="flex items-center gap-3">
<button
onClick={() => navigate("/projects")}
className="text-gray-400 hover:text-gray-200 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
<h1 className="text-xl font-semibold">{project.name}</h1>
</div>
{/* Config */}
<section className="bg-gray-900 border border-gray-800 rounded-lg p-5 space-y-4">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
Configuration
</h2>
<div className="space-y-1">
<label className="text-xs text-gray-400">Project Name</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-gray-400">Trigger Branch</label>
<input
value={form.trigger_branch}
onChange={(e) =>
setForm((f) => ({ ...f, trigger_branch: e.target.value }))
}
className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-gray-400">Shell Script</label>
<textarea
value={form.shell_script}
onChange={(e) =>
setForm((f) => ({ ...f, shell_script: e.target.value }))
}
rows={8}
spellCheck={false}
className="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm font-mono outline-none focus:border-blue-500 resize-y"
placeholder="cd /path/to/project&#10;git pull&#10;pnpm install&#10;pnpm build&#10;pm2 restart app"
/>
</div>
<div className="flex gap-2">
<button
onClick={save}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white text-sm px-4 py-2 rounded-md transition-colors"
>
<Save className="w-4 h-4" />
{saved ? "Saved!" : "Save"}
</button>
<button
onClick={triggerBuild}
disabled={triggering}
className="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
>
<Play className="w-4 h-4" />
{triggering ? "Triggering..." : "Run Now"}
</button>
</div>
</section>
{/* Webhook */}
<section className="bg-gray-900 border border-gray-800 rounded-lg p-5 space-y-4">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
Webhook
</h2>
<div className="space-y-1">
<label className="text-xs text-gray-400">Webhook URL</label>
<div className="flex gap-2">
<input
readOnly
value={webhookUrl}
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm font-mono text-gray-300 outline-none"
/>
<button
onClick={() => {
navigator.clipboard.writeText(webhookUrl);
}}
className="text-gray-400 hover:text-gray-200 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-gray-400">Secret</label>
<div className="flex gap-2">
<input
readOnly
type={secretVisible ? "text" : "password"}
value={project.webhook_secret}
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm font-mono text-gray-300 outline-none"
/>
<button
onClick={() => setSecretVisible((v) => !v)}
className="text-gray-400 hover:text-gray-200 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
>
{secretVisible ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
<button
onClick={copySecret}
className="text-gray-400 hover:text-gray-200 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
>
{copied ? (
<span className="text-xs px-1">Copied</span>
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
onClick={regenerateSecret}
className="text-gray-400 hover:text-yellow-400 bg-gray-800 border border-gray-700 px-3 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
</section>
{/* Builds */}
<section className="space-y-3">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-wider">
Recent Builds
</h2>
{builds.length === 0 ? (
<p className="text-gray-500 text-sm">
No builds yet. Push to the trigger branch to start one.
</p>
) : (
<ul className="space-y-2">
{builds.map((b) => (
<li key={b.id}>
<Link
to={`/builds/${b.id}`}
className="flex items-center justify-between bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-4 py-3 transition-colors"
>
<div className="flex items-center gap-3">
<Play className="w-3.5 h-3.5 text-gray-500" />
<span className="text-sm font-mono text-gray-300">
#{b.id}
</span>
<span className="text-xs text-gray-500">
{b.trigger_ref}
</span>
</div>
<span
className={`text-xs font-medium ${STATUS_COLOR[b.status] ?? "text-gray-400"}`}
>
{b.status}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, Trash2, ChevronRight } from "lucide-react";
interface Project {
id: number;
name: string;
trigger_branch: string;
created_at: number;
}
export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const navigate = useNavigate();
const load = () =>
fetch("/api/projects")
.then((r) => r.json())
.then(setProjects);
useEffect(() => {
load();
}, []);
const create = async () => {
if (!newName.trim()) return;
await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
setNewName("");
setCreating(false);
load();
};
const remove = async (e: React.MouseEvent, id: number) => {
e.stopPropagation();
if (!confirm("Delete this project and all its builds?")) return;
await fetch(`/api/projects/${id}`, { method: "DELETE" });
load();
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold">Projects</h1>
<button
onClick={() => setCreating(true)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white text-sm px-3 py-2 rounded-md transition-colors"
>
<Plus className="w-4 h-4" /> New Project
</button>
</div>
{creating && (
<div className="mb-4 flex gap-2">
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create();
if (e.key === "Escape") setCreating(false);
}}
placeholder="Project name"
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm outline-none focus:border-blue-500"
/>
<button
onClick={create}
className="bg-blue-600 hover:bg-blue-500 text-white text-sm px-4 py-2 rounded-md transition-colors"
>
Create
</button>
<button
onClick={() => setCreating(false)}
className="text-gray-400 hover:text-gray-200 text-sm px-3 py-2 rounded-md transition-colors"
>
Cancel
</button>
</div>
)}
{projects.length === 0 && !creating ? (
<p className="text-gray-500 text-sm">
No projects yet. Create one to get started.
</p>
) : (
<ul className="space-y-2">
{projects.map((p) => (
<li
key={p.id}
onClick={() => navigate(`/projects/${p.id}`)}
className="flex items-center justify-between bg-gray-900 border border-gray-800 hover:border-gray-600 rounded-lg px-4 py-3 cursor-pointer transition-colors group"
>
<div>
<p className="font-medium text-sm">{p.name}</p>
<p className="text-xs text-gray-500 mt-0.5">
branch: {p.trigger_branch}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => remove(e, p.id)}
className="text-gray-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
<ChevronRight className="w-4 h-4 text-gray-600" />
</div>
</li>
))}
</ul>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout.tsx","./src/pages/builddetail.tsx","./src/pages/projectdetail.tsx","./src/pages/projects.tsx"],"version":"5.9.3"}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "../server/public",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
});